super-ux 0.59.1 → 0.59.3

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,37 @@
1
+ ## 0.59.3 — accountable journeys and explicit copy inputs
2
+
3
+ - Journeys record an Owner, with `unknown` supported. `U086` advises migration
4
+ for missing/blank/duplicate owners without assigning anyone automatically.
5
+ - Marketing writing uses a current `copy-brief.md` with success metric,
6
+ constraints and a reference to the voice pair alongside the original inputs.
7
+ `B074` advises existing packs; `B075` checks present records. Unresolved values
8
+ remain `unknown`; unrelated tasks do not load a brief.
9
+ - Seeds and every per-skill contract copy are synchronized. Discovery descriptions
10
+ are unchanged. No installation or provider operation was performed.
11
+ - CI uses nightly runs and deliberate ready-for-review promotion, preserving jobs.
12
+ [FW05 source receipt](docs/evidence/runs/2026-10-13-fw05-journey-copy.md).
13
+
14
+ ## 0.59.2 — brand-lint names the line, and a release refuses a lightweight tag
15
+
16
+ - **`B022` reports the line.** An unregistered literal was reported at line 0 —
17
+ `B022 bin/super-ux.js:0` for a string on line 303 — so the finding named a file
18
+ and left the search to the reader. Code literals now carry the line their first
19
+ character is on (template-literal paragraphs included), HTML controls the line
20
+ they open on. The comment scanner keeps a block comment's newlines so a stripped
21
+ comment cannot shift the count. Fixture `b022_line_cases` in
22
+ `test/brand_lint_test.py`; three plants (line 0 back, comment newlines dropped,
23
+ HTML line pinned) each turned it red.
24
+ - **The probe string that fired it is retired, not registered.** `claude --version`
25
+ in `bin/super-ux.js` was the `command` field of the typed result returned when the
26
+ claude CLI is absent; nothing prints it. B022's doctrine is "agree it or retire
27
+ it", and a row in `strings.md` would register as interface copy a string no
28
+ reader sees, so the field is now derived from the probe's own argv, the way
29
+ `run()` derives every other `command`. `python3 docs/brand/lint.py` is clean.
30
+ - **The release refuses a lightweight tag.** A step right after checkout in
31
+ `release.yml` fails unless `git cat-file -t <tag>` is `tag`. `git describe` and
32
+ `git submodule status` see annotated tags only; v0.59.1 was cut lightweight and
33
+ the hub read super-ux as its previous release.
34
+
1
35
  ## 0.59.1 — inventories and audits work without delegation
2
36
 
3
37
  Brand inventory, scenario inventory and UX audit now use the same areas, batches
package/README.md CHANGED
@@ -161,7 +161,7 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
161
161
  python3 docs/brand/lint.py
162
162
  ```
163
163
 
164
- 46 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
164
+ 48 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
165
165
  with no sourced fact, a field over its limit with the locale coefficient
166
166
  applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
167
167
  a rhetorical dash, a title that ends in a full stop, a locale that lags
package/bin/super-ux.js CHANGED
@@ -294,13 +294,17 @@ function printUpdateLine() {
294
294
 
295
295
  function installClaudePlugin() {
296
296
  console.log(`\n--- Claude Code plugin ---`);
297
- const probe = spawnSync('claude', ['--version'], { stdio: 'ignore' });
297
+ // The probe's argv is the one source of its `command`, exactly as run()
298
+ // derives it: the typed result carries it, nothing prints it, so it is not
299
+ // interface copy and gets no strings.md row (brand-lint B022, retired).
300
+ const probeArgv = ['claude', '--version'];
301
+ const probe = spawnSync(probeArgv[0], probeArgv.slice(1), { stdio: 'ignore' });
298
302
  if (probe.error && probe.error.code === 'ENOENT') {
299
303
  console.log(`claude CLI not found. Run inside Claude Code instead:
300
304
  /plugin marketplace add ${REPO}
301
305
  /plugin install super-ux@super-ux`);
302
306
  return { status: 'unsupported', code: null, signal: null,
303
- command: 'claude --version' };
307
+ command: probeArgv.join(' ') };
304
308
  }
305
309
  if (run('claude', ['plugin', 'marketplace', 'add', REPO]).status !== 'ok') {
306
310
  console.log('(marketplace may already be added, continuing)');
@@ -21,6 +21,10 @@ locale policy. Missing terms or facts are reported, never invented. This pass
21
21
  owns product text and `strings.md` rows (`Status: proposed`); claim coordinated
22
22
  files before editing. A file living in `docs/brand/` does not forbid the edit.
23
23
 
24
+ For marketing writing only, use the copywriting skill’s `marketing-copy.md`
25
+ → Before writing contract for the current brief; keep unknown inputs explicit.
26
+ Other tasks do not load this brief.
27
+
24
28
  ## Rules that hold in every voice
25
29
 
26
30
  - **No decorative full stop in headings or labels**, even when inline markup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.59.1",
3
+ "version": "0.59.3",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
6
6
  "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
@@ -81,7 +81,7 @@ STRING_KINDS = ("copy", "layout")
81
81
 
82
82
  SEVERITY_ERROR = "error"
83
83
  SEVERITY_WARN = "warn"
84
- WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072".split())
84
+ WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B044 B045 B046 B051 B053 B054 B060 B063 B064 B066 B071 B072 B074".split())
85
85
 
86
86
  Finding = namedtuple("Finding", "code severity path line message")
87
87
 
@@ -560,16 +560,38 @@ def code_literals(text: str) -> list[str]:
560
560
  Comments must already be stripped by the caller -- `_strip_comments` leaves
561
561
  literals untouched, which is what makes both patterns below safe to run.
562
562
  """
563
- out: list[str] = [lit for _quote, lit in LITERAL_RE.findall(text)]
564
- for block in TEMPLATE_RE.findall(text):
563
+ return [lit for lit, _line in code_literals_at(text)]
564
+
565
+
566
+ def code_literals_at(text: str) -> list[tuple[str, int]]:
567
+ """`code_literals`, each with the 1-based line its first character is on.
568
+
569
+ B022 reported every unregistered literal at line 0 -- `bin/super-ux.js:0`
570
+ for a string on line 303 -- so the finding named a file and left the reader
571
+ to search it. The line is counted in the comment-stripped text, which
572
+ `_strip_comments` keeps line-for-line with the source (a block comment
573
+ leaves its newlines behind), so the number is the source's own.
574
+ """
575
+ def line_at(offset: int) -> int:
576
+ return text.count("\n", 0, offset) + 1
577
+
578
+ out: list[tuple[str, int]] = [
579
+ (m.group(2), line_at(m.start(2))) for m in LITERAL_RE.finditer(text)
580
+ ]
581
+ for m in TEMPLATE_RE.finditer(text):
582
+ block = m.group(1)
565
583
  if "\n" not in block:
566
584
  continue # single-line backticks are already in `out`
585
+ cursor = 0
567
586
  for para in re.split(r"\n\s*\n", block):
587
+ at = block.find(para, cursor)
588
+ cursor = at + len(para)
568
589
  joined = " ".join(
569
590
  line.strip() for line in para.splitlines() if line.strip()
570
591
  )
571
592
  if len(joined) >= 3:
572
- out.append(joined)
593
+ lead = len(para) - len(para.lstrip())
594
+ out.append((joined, line_at(m.start(1) + at + lead)))
573
595
  return out
574
596
 
575
597
  # A template literal's interpolations split it into pieces, and the pieces are
@@ -742,10 +764,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
742
764
  body = read(target) or ""
743
765
  html = html_copy(body) if target.suffix.lower() in HTML_SUFFIXES else None
744
766
  source_copy = " ".join(html.blocks) if html is not None else body
745
- literals = html.interface_texts if html is not None else [
746
- lit for lit in code_literals(_strip_comments(body, target.suffix))
747
- if _looks_like_copy(lit)
748
- ]
767
+ located = list(zip(html.interface_texts, html.interface_lines)) \
768
+ if html is not None else [
769
+ (lit, line) for lit, line
770
+ in code_literals_at(_strip_comments(body, target.suffix))
771
+ if _looks_like_copy(lit)
772
+ ]
773
+ literals = [lit for lit, _line in located]
749
774
  if literals or html is not None:
750
775
  # The registry records what a reader sees, so the built page is the
751
776
  # authority whenever there is one. Source is the fallback and stays
@@ -781,10 +806,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
781
806
  swept.add(file_part)
782
807
  known = {r["text"] for r in rows}
783
808
  trimmed = {t.strip() for t in known}
784
- for lit in dict.fromkeys(literals): # one report per literal
809
+ first_line: dict[str, int] = {}
810
+ for lit, line in located: # one report per literal, at its first line
811
+ first_line.setdefault(lit, line)
812
+ for lit, line in first_line.items():
785
813
  if lit not in known and lit.strip() not in trimmed:
786
814
  findings.append(Finding(
787
- "B022", SEVERITY_WARN, f"{file_part}", 0,
815
+ "B022", SEVERITY_WARN, f"{file_part}", line,
788
816
  f"\"{lit}\" is in the code with no registry row -- "
789
817
  f"agree it or retire it",
790
818
  ))
@@ -927,6 +955,7 @@ class HTMLCopy(HTMLParser):
927
955
  self.last_anchor_end = None
928
956
  self.controls = []
929
957
  self.interface_texts = []
958
+ self.interface_lines = [] # parallel to interface_texts: the control's opening line
930
959
 
931
960
  @property
932
961
  def blocked(self):
@@ -939,7 +968,7 @@ class HTMLCopy(HTMLParser):
939
968
  attrs.get("style", ""), re.I))
940
969
  if not hidden:
941
970
  if tag in self.CONTROLS or attrs.get("role", "").lower() in self.INTERFACE_ROLES:
942
- self.controls.append((tag, len(self.stack), []))
971
+ self.controls.append((tag, len(self.stack), [], self.getpos()[0]))
943
972
  if tag == "br" or tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag):
944
973
  for control in self.controls:
945
974
  control[2].append(" ")
@@ -978,6 +1007,7 @@ class HTMLCopy(HTMLParser):
978
1007
  text = " ".join("".join(control[2]).split())
979
1008
  if text:
980
1009
  self.interface_texts.append(text)
1010
+ self.interface_lines.append(control[3])
981
1011
  self.controls.remove(control)
982
1012
  if tag == "a" and not self.blocked:
983
1013
  self.last_anchor_end = len(self.parts)
@@ -1106,7 +1136,12 @@ def _strip_comments(text: str, suffix: str) -> str:
1106
1136
  continue
1107
1137
  if not hashed and text.startswith("/*", i):
1108
1138
  end = text.find("*/", i + 2)
1109
- i = n if end == -1 else end + 2
1139
+ stop = n if end == -1 else end + 2
1140
+ # Keep the comment's newlines: B022 reports a literal by its line
1141
+ # in THIS text, so a stripped multi-line comment must not shift
1142
+ # every line after it.
1143
+ out.append("\n" * text.count("\n", i, stop))
1144
+ i = stop
1110
1145
  continue
1111
1146
  out.append(ch)
1112
1147
  i += 1
@@ -2539,6 +2574,45 @@ def apply_fixes(brand_dir: Path, findings: list[Finding]) -> int:
2539
2574
  return rewritten
2540
2575
 
2541
2576
 
2577
+ COPY_BRIEF_FIELDS = ("Surface", "Action", "Reader", "Customer words", "Proof",
2578
+ "Success metric", "Constraints", "Voice reference")
2579
+
2580
+
2581
+ def check_copy_brief(brand_dir: Path, sources: dict) -> list[Finding]:
2582
+ """B074/B075: one current marketing brief, with explicit unknowns permitted."""
2583
+ path = brand_dir / "copy-brief.md"
2584
+ if not path.exists():
2585
+ return [Finding("B074", SEVERITY_WARN, "copy-brief.md", 0,
2586
+ "marketing sources have no current copy brief; seed one "
2587
+ "before the next writing task, retaining unknown inputs")] if sources.get("marketing") else []
2588
+ text = read(path)
2589
+ if text is None:
2590
+ return [Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
2591
+ "current copy brief cannot be read")]
2592
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
2593
+ fields = {}
2594
+ findings = []
2595
+ for field in COPY_BRIEF_FIELDS:
2596
+ values = re.findall(rf"(?m)^{re.escape(field)}:[ \t]*(.*)$", text)
2597
+ if len(values) != 1 or unfilled(values[0]):
2598
+ findings.append(Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
2599
+ f"needs one nonempty {field}: value; use unknown for an unresolved input"))
2600
+ else:
2601
+ fields[field] = values[0].strip()
2602
+ ref = fields.get("Voice reference", "unknown")
2603
+ if ref.lower() != "unknown":
2604
+ voice = re.sub(r"<!--.*?-->", "", read(brand_dir / "voice.md") or "", flags=re.S)
2605
+ section = re.search(r"(?ms)^## Voice references[ \t]*\n(.*?)(?=^## |\Z)", voice)
2606
+ pair = [re.search(rf"(?m)^[ \t]*(?:-[ \t]*)?\*\*{field}:\*\*[ \t]*(.*)$", section[1] if section else "")
2607
+ for field in ("Admired", "Refused")]
2608
+ if (ref != "voice.md#voice-references" or not section
2609
+ or any(not m or unfilled(m[1]) or m[1].strip().lower() == "unknown" for m in pair)):
2610
+ findings.append(Finding("B075", SEVERITY_ERROR, "copy-brief.md", 0,
2611
+ "Voice reference must be unknown or voice.md#voice-references "
2612
+ "with a filled Admired/Refused pair in voice.md"))
2613
+ return findings
2614
+
2615
+
2542
2616
  def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
2543
2617
  """Every check, in order. `fix` is applied by the caller via apply_fixes."""
2544
2618
  sources = load_sources(brand_dir)
@@ -2549,6 +2623,7 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
2549
2623
  findings.extend(check_consistency(brand_dir, sources))
2550
2624
  findings.extend(check_facts(brand_dir, sources))
2551
2625
  findings.extend(check_channels(brand_dir, sources))
2626
+ findings.extend(check_copy_brief(brand_dir, sources))
2552
2627
  findings.extend(check_short_video(brand_dir, sources))
2553
2628
  findings.extend(check_bot_safety(brand_dir, sources))
2554
2629
  findings.extend(check_ai_tells(brand_dir, sources))
@@ -735,6 +735,16 @@ def check_jobs(foundation: str, scenarios: str = "", flows: str = "",
735
735
  f"{', '.join(JOB_FIELDS)}")
736
736
 
737
737
 
738
+ def check_journey_owners(foundation: str) -> None:
739
+ """U086: absence is migration advice; unknown is an explicit unassigned owner."""
740
+ for sid, body in sorted(entry_blocks(foundation, "JRN").items()):
741
+ owners = re.findall(r"(?m)^[ \t]*(?:-[ \t]*)?\*\*Owner:\*\*[ \t]*(.*)$", body)
742
+ if len(owners) != 1 or _unfilled(owners[0]):
743
+ warn(f"[U086] foundation.md: {sid} needs one **Owner:** value — "
744
+ "name the accountable role/person or write unknown; "
745
+ "do not invent an assignment")
746
+
747
+
738
748
  WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
739
749
 
740
750
 
@@ -1251,6 +1261,7 @@ def main() -> int:
1251
1261
  check_field_vocabulary(scenarios, foundation)
1252
1262
  check_status_enums(scenarios, foundation, screens, flows, vision)
1253
1263
  check_jobs(foundation, scenarios, flows, screens)
1264
+ check_journey_owners(foundation)
1254
1265
  check_vision(ux, vision)
1255
1266
  check_web_surface(screens, flows)
1256
1267
  check_onboarding(flows, ux, project_root)
@@ -15,6 +15,7 @@ Owned by the `brand-voice` skill. Written by `copywriting`. Checked by
15
15
  | `terminology.md` | our words, banned words, entity and tier names |
16
16
  | `facts.md` | canonical numbers and proof — the only source of a figure |
17
17
  | `channels.md` | one record per surface: register, limits, bans |
18
+ | `copy-brief.md` | current marketing task inputs; unknowns stay explicit |
18
19
  | `strings.md` | interface string registry → `file:line` → scenario |
19
20
  | `locales/<code>.md` | per-locale delta |
20
21
 
@@ -0,0 +1,10 @@
1
+ Contract: brand-contract v1
2
+
3
+ Surface: unknown
4
+ Action: unknown
5
+ Reader: unknown
6
+ Customer words: unknown
7
+ Proof: unknown
8
+ Success metric: unknown
9
+ Constraints: unknown
10
+ Voice reference: unknown
@@ -23,6 +23,7 @@ data/observation, recognizable by a real user. -->
23
23
  ## Customer journeys
24
24
 
25
25
  <!-- ### JRN-01: <persona> — <job> (JTBD-01)
26
+ - **Owner:** unknown
26
27
  | # | Stage | User action | Touchpoint | Emotion (1-5) | Pain | Opportunity |
27
28
  |---|-------|------------|------------|---------------|------|-------------|
28
29
  -->