@natjswenson/press 0.1.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/lib/lint.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The brand lint — the mechanical half of laws.md.
3
+ *
4
+ * Every rule here exists because a real artifact shipped wrong: a résumé that
5
+ * looked perfect and was unparseable to an ATS, a PDF where the warning glyph
6
+ * turned into a second loud color, a card that drifted a hex by one digit. Prose
7
+ * rules an agent reads are necessary but not sufficient; these are the ones a
8
+ * machine can hold.
9
+ *
10
+ * Every rule is two-sided in the tests: a real artifact must pass, and a mutated
11
+ * copy of that same artifact must fail. A one-sided lint rots silently the day
12
+ * someone weakens the checker.
13
+ */
14
+ export const RULES = [
15
+ 'off-palette-hex',
16
+ 'tracking-max',
17
+ 'emoji-presentation',
18
+ 'no-shadow',
19
+ 'no-gradient',
20
+ 'no-radius',
21
+ 'accent-cap',
22
+ ];
23
+
24
+ const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
25
+ const TRACKING_RE = /letter-spacing\s*:\s*(-?[\d.]+)\s*em/gi;
26
+ // The negative lookahead sits immediately after the colon: with a `\s*` in
27
+ // front of it the engine backtracks to zero width and steps straight past the
28
+ // guard, so `box-shadow: none` would report as a shadow.
29
+ const SHADOW_RE = /\b(box|text)-shadow\s*:(?!\s*none\b)/gi;
30
+ const GRADIENT_RE = /\b(linear|radial|conic)-gradient\s*\(/gi;
31
+ const RADIUS_RE = /\bborder-radius\s*:(?!\s*0[a-z%]*\s*[;}!])/gi;
32
+ /** U+26A0 not already followed by the text-presentation selector U+FE0E. */
33
+ const BARE_WARN_RE = /⚠(?!︎)/g;
34
+
35
+ const DISABLE_RE = /press-lint-disable-next-line\s+([a-z-]+(?:\s*,\s*[a-z-]+)*)/;
36
+
37
+ /**
38
+ * @param {string} text file contents
39
+ * @param {object} tokens resolved token set
40
+ * @param {object} options { file, waivers: string[], accentCap: number|null,
41
+ * rules: string[] }
42
+ */
43
+ export function lintText(text, tokens, options = {}) {
44
+ const file = options.file ?? '<input>';
45
+ const enabled = new Set(options.rules ?? RULES);
46
+ const waived = new Set(options.waivers ?? []);
47
+ const palette = new Set(
48
+ [...tokens.palette, ...(options.extraPalette ?? [])].map((c) => c.toLowerCase()),
49
+ );
50
+ const lines = text.split('\n');
51
+ const findings = [];
52
+
53
+ const add = (rule, lineNo, message) => {
54
+ if (!enabled.has(rule) || waived.has(rule)) return;
55
+ if (isDisabled(lines, lineNo, rule)) return;
56
+ findings.push({ rule, file, line: lineNo, message });
57
+ };
58
+
59
+ lines.forEach((line, i) => {
60
+ const lineNo = i + 1;
61
+
62
+ for (const m of line.matchAll(HEX_RE)) {
63
+ const hex = normalizeHex(m[0]);
64
+ if (hex && !palette.has(hex)) {
65
+ add('off-palette-hex', lineNo, `${m[0]} is not a brand token — add it to tokens.json or use an existing one`);
66
+ }
67
+ }
68
+
69
+ // The tracking ceiling protects *text extraction*, so it applies to
70
+ // documents a machine will read back — PDFs, HTML pages — and not to
71
+ // rasterised cards, whose type is pixels by the time anyone sees it. The
72
+ // card set really does run the eyebrow at .16em and is right to.
73
+ if (options.textExtractable !== false) {
74
+ for (const m of line.matchAll(TRACKING_RE)) {
75
+ const em = Math.abs(Number.parseFloat(m[1]));
76
+ const max = tokens.limits.max_letter_spacing_em;
77
+ if (em > max) {
78
+ add('tracking-max', lineNo, `letter-spacing ${m[1]}em exceeds ${max}em — above this, PDF text extraction silently breaks`);
79
+ }
80
+ }
81
+ }
82
+
83
+ for (const _ of line.matchAll(BARE_WARN_RE)) {
84
+ add('emoji-presentation', lineNo, 'bare U+26A0 renders as a colored emoji — use the text-presentation form from tokens.marks.warn');
85
+ }
86
+ for (const _ of line.matchAll(SHADOW_RE)) {
87
+ add('no-shadow', lineNo, 'shadows are not part of the brand — structure is ink rules and whitespace');
88
+ }
89
+ for (const _ of line.matchAll(GRADIENT_RE)) {
90
+ add('no-gradient', lineNo, 'gradients are not part of the brand — paper is flat');
91
+ }
92
+ for (const _ of line.matchAll(RADIUS_RE)) {
93
+ add('no-radius', lineNo, 'rounded corners are not part of the brand (a circular avatar is the one exception — waive it explicitly)');
94
+ }
95
+ });
96
+
97
+ const cap = options.accentCap;
98
+ if (cap !== null && cap !== undefined && enabled.has('accent-cap') && !waived.has('accent-cap')) {
99
+ const accent = tokens.colors.accent.toLowerCase();
100
+ const uses = text.toLowerCase().split(accent).length - 1;
101
+ if (uses > cap) {
102
+ findings.push({
103
+ rule: 'accent-cap',
104
+ file,
105
+ line: 0,
106
+ message: `the accent appears ${uses} times, cap is ${cap} — one loud moment per document`,
107
+ });
108
+ }
109
+ }
110
+
111
+ return { file, findings, ok: findings.length === 0 };
112
+ }
113
+
114
+ /** A `press-lint-disable-next-line <rule>` comment on the preceding line. */
115
+ function isDisabled(lines, lineNo, rule) {
116
+ const prev = lines[lineNo - 2];
117
+ if (!prev) return false;
118
+ const m = DISABLE_RE.exec(prev);
119
+ if (!m) return false;
120
+ return m[1].split(',').map((r) => r.trim()).includes(rule);
121
+ }
122
+
123
+ /** #abc -> #aabbcc; #rrggbbaa -> #rrggbb. Returns null for lengths we skip. */
124
+ function normalizeHex(raw) {
125
+ const body = raw.slice(1).toLowerCase();
126
+ if (body.length === 3) return `#${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}`;
127
+ if (body.length === 6) return `#${body}`;
128
+ if (body.length === 8) return `#${body.slice(0, 6)}`;
129
+ return null;
130
+ }
package/lib/region.mjs ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Marked regions — how press owns part of a file without owning the file.
3
+ *
4
+ * A consumer's brand file is mostly hand-written, medium-specific code: a
5
+ * 250-line stylesheet, a poster's geometry, a résumé's layout. Only the token
6
+ * block and its loader are shared. So press splices a *marked region* into an
7
+ * otherwise untouched file rather than generating the file, which is also what
8
+ * keeps a whole-file sync from clobbering hand-written work (the personal avatar
9
+ * footer in ghostwriter's diagram.css was lost that way once already).
10
+ *
11
+ * The start marker carries a receipt — the press version and a hash of the body
12
+ * — so a hand-edit inside the region is detectable without re-running the
13
+ * emitter, and so a diff shows *which* release last wrote it.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+
17
+ /** Comment syntaxes a region can be expressed in. */
18
+ export const SYNTAXES = {
19
+ python: { prefix: '# ', suffix: '' },
20
+ css: { prefix: '/* ', suffix: ' */' },
21
+ md: { prefix: '<!-- ', suffix: ' -->' },
22
+ };
23
+
24
+ const BANNER = 'GENERATED by @natjswenson/press, do not edit';
25
+
26
+ export class RegionError extends Error {}
27
+
28
+ function syntaxOf(name) {
29
+ const s = SYNTAXES[name];
30
+ if (!s) {
31
+ throw new RegionError(
32
+ `unknown syntax "${name}" (expected one of: ${Object.keys(SYNTAXES).join(', ')})`,
33
+ );
34
+ }
35
+ return s;
36
+ }
37
+
38
+ const wrap = (syntax, text) => `${syntax.prefix}${text}${syntax.suffix}`;
39
+
40
+ /**
41
+ * The inner text of a comment line, or null when the line is not a comment in
42
+ * this syntax. Trailing whitespace is tolerated; leading is not, because these
43
+ * markers are always at column 0.
44
+ */
45
+ function unwrap(syntax, line) {
46
+ const trimmed = line.replace(/\s+$/, '');
47
+ if (!trimmed.startsWith(syntax.prefix)) return null;
48
+ if (syntax.suffix && !trimmed.endsWith(syntax.suffix)) return null;
49
+ return trimmed.slice(syntax.prefix.length, trimmed.length - syntax.suffix.length);
50
+ }
51
+
52
+ /** Short, stable content hash of a region body. */
53
+ export function bodyHash(body) {
54
+ const normalized = body.replace(/\r\n/g, '\n').replace(/\s+$/, '');
55
+ return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 12);
56
+ }
57
+
58
+ export function startMarker(syntaxName, region, version, hash) {
59
+ const syntax = syntaxOf(syntaxName);
60
+ return wrap(syntax, `>>> press:${region} v${version} sha256:${hash} ${BANNER}`);
61
+ }
62
+
63
+ export function endMarker(syntaxName, region) {
64
+ return wrap(syntaxOf(syntaxName), `<<< press:${region}`);
65
+ }
66
+
67
+ /**
68
+ * Locate a region. Returns null when the file has no such region — callers
69
+ * decide whether that is a migration opportunity (`emit --init`) or a failure
70
+ * (`check`, where a vanished region must go red rather than silently pass).
71
+ */
72
+ export function findRegion(text, region, syntaxName) {
73
+ const syntax = syntaxOf(syntaxName);
74
+ const lines = text.split('\n');
75
+ const openRe = new RegExp(`^>>> press:${escapeRe(region)}(?:\\s|$)`);
76
+ const closeRe = new RegExp(`^<<< press:${escapeRe(region)}\\s*$`);
77
+
78
+ let start = -1;
79
+ for (let i = 0; i < lines.length; i += 1) {
80
+ const inner = unwrap(syntax, lines[i]);
81
+ if (inner !== null && openRe.test(inner)) {
82
+ if (start !== -1) {
83
+ throw new RegionError(
84
+ `region "${region}" opens twice (lines ${start + 1} and ${i + 1}) — a file may declare it only once`,
85
+ );
86
+ }
87
+ start = i;
88
+ }
89
+ }
90
+ if (start === -1) return null;
91
+
92
+ let end = -1;
93
+ for (let i = start + 1; i < lines.length; i += 1) {
94
+ const inner = unwrap(syntax, lines[i]);
95
+ if (inner !== null && closeRe.test(inner)) {
96
+ end = i;
97
+ break;
98
+ }
99
+ }
100
+ if (end === -1) {
101
+ throw new RegionError(
102
+ `region "${region}" opens at line ${start + 1} but never closes — expected a line reading ${JSON.stringify(endMarker(syntaxName, region))}`,
103
+ );
104
+ }
105
+
106
+ const openInner = unwrap(syntax, lines[start]);
107
+ const receipt = /\bv(\S+)\s+sha256:([0-9a-f]+)/.exec(openInner);
108
+
109
+ return {
110
+ startLine: start,
111
+ endLine: end,
112
+ body: lines.slice(start + 1, end).join('\n'),
113
+ version: receipt ? receipt[1] : null,
114
+ hash: receipt ? receipt[2] : null,
115
+ };
116
+ }
117
+
118
+ /** Rebuild the full marked block (start marker, body, end marker). */
119
+ export function renderRegion(region, syntaxName, body, version) {
120
+ const trimmed = body.replace(/\s+$/, '');
121
+ return [
122
+ startMarker(syntaxName, region, version, bodyHash(trimmed)),
123
+ trimmed,
124
+ endMarker(syntaxName, region),
125
+ ].join('\n');
126
+ }
127
+
128
+ /** Replace an existing region's block in `text`. Throws if there isn't one. */
129
+ export function spliceRegion(text, region, syntaxName, body, version) {
130
+ const found = findRegion(text, region, syntaxName);
131
+ if (!found) {
132
+ throw new RegionError(
133
+ `${text.length === 0 ? 'empty file' : 'file'} has no press:${region} region — run \`press emit --target <id> --init\` to create one`,
134
+ );
135
+ }
136
+ const lines = text.split('\n');
137
+ const block = renderRegion(region, syntaxName, body, version).split('\n');
138
+ lines.splice(found.startLine, found.endLine - found.startLine + 1, ...block);
139
+ return lines.join('\n');
140
+ }
141
+
142
+ /**
143
+ * First-time insertion, for migrating a consumer that still carries a
144
+ * hand-written copy.
145
+ *
146
+ * `anchor.replaceFrom`/`anchor.replaceTo` are regexes naming the first and last
147
+ * line of the legacy span the region takes over, so the duplicate is swallowed
148
+ * in the same operation rather than left behind to drift. With no anchor the
149
+ * block is appended.
150
+ */
151
+ export function initRegion(text, region, syntaxName, body, version, anchor = {}) {
152
+ if (findRegion(text, region, syntaxName)) {
153
+ throw new RegionError(`file already has a press:${region} region — use emit without --init`);
154
+ }
155
+ const block = renderRegion(region, syntaxName, body, version);
156
+ const { replaceFrom, replaceTo } = anchor;
157
+ if (!replaceFrom) return `${text.replace(/\s+$/, '')}\n\n${block}\n`;
158
+
159
+ const lines = text.split('\n');
160
+ const fromRe = new RegExp(replaceFrom);
161
+ const from = lines.findIndex((l) => fromRe.test(l));
162
+ if (from === -1) {
163
+ throw new RegionError(`anchor replaceFrom /${replaceFrom}/ matched no line`);
164
+ }
165
+ const toRe = new RegExp(replaceTo ?? replaceFrom);
166
+ let to = -1;
167
+ for (let i = from; i < lines.length; i += 1) {
168
+ if (toRe.test(lines[i]) && (i > from || !replaceTo)) {
169
+ to = i;
170
+ break;
171
+ }
172
+ }
173
+ if (to === -1) {
174
+ throw new RegionError(`anchor replaceTo /${replaceTo}/ matched no line at or after line ${from + 1}`);
175
+ }
176
+ lines.splice(from, to - from + 1, ...block.split('\n'));
177
+ return lines.join('\n');
178
+ }
179
+
180
+ function escapeRe(s) {
181
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
182
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The consumer registry.
3
+ *
4
+ * A target is one region in one file in one repo. Declaring a consumer here is
5
+ * what makes it press's business; anything not declared is invisible to
6
+ * `check`, which is why `doctor` exists to show the whole registry rather than
7
+ * only what resolved locally.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const TARGETS_PATH = join(HERE, '..', 'targets.json');
15
+
16
+ export class TargetError extends Error {}
17
+
18
+ export function loadTargets(path = TARGETS_PATH) {
19
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
20
+ const targets = raw.targets ?? [];
21
+ const seen = new Set();
22
+ for (const t of targets) {
23
+ for (const key of ['id', 'repo', 'path', 'region', 'emitter', 'syntax']) {
24
+ if (!t[key]) throw new TargetError(`target ${t.id ?? '<unnamed>'} is missing "${key}"`);
25
+ }
26
+ if (seen.has(t.id)) throw new TargetError(`duplicate target id "${t.id}"`);
27
+ seen.add(t.id);
28
+ }
29
+ return targets;
30
+ }
31
+
32
+ /** Nearest ancestor containing a .git, or the path itself. */
33
+ export function repoRoot(start = process.cwd()) {
34
+ let dir = resolve(start);
35
+ for (;;) {
36
+ if (existsSync(join(dir, '.git'))) return dir;
37
+ const parent = dirname(dir);
38
+ if (parent === dir) return resolve(start);
39
+ dir = parent;
40
+ }
41
+ }
42
+
43
+ export const targetPath = (target, root) =>
44
+ isAbsolute(target.path) ? target.path : join(root, target.path);
45
+
46
+ /**
47
+ * Which targets this invocation is responsible for.
48
+ *
49
+ * Selection is by *file presence* under the repo root, so the same registry
50
+ * works unchanged whether it runs inside claude-skills, budget, or the site.
51
+ * An explicit `--target` always selects, present or not, so a typo'd path
52
+ * reports as missing rather than silently selecting nothing.
53
+ */
54
+ export function selectTargets(targets, { root, ids }) {
55
+ if (ids?.length) {
56
+ return ids.map((id) => {
57
+ const found = targets.find((t) => t.id === id);
58
+ if (!found) {
59
+ throw new TargetError(
60
+ `no target "${id}" — known targets: ${targets.map((t) => t.id).join(', ')}`,
61
+ );
62
+ }
63
+ return found;
64
+ });
65
+ }
66
+ return targets.filter((t) => existsSync(targetPath(t, root)));
67
+ }
package/lib/tokens.mjs ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Loading and resolving the token set.
3
+ *
4
+ * Nothing else in the codebase may hard-code a brand value. If a consumer needs
5
+ * a color that isn't here, the answer is to add it here — that is the entire
6
+ * point of the skill, and the terminal-panel quartet spent three files
7
+ * undeclared before it was.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, join } from 'node:path';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const BRAND_DIR = join(HERE, '..', 'brand');
15
+ export const TOKENS_PATH = join(BRAND_DIR, 'tokens.json');
16
+
17
+ let cached = null;
18
+
19
+ export function loadTokens(path = TOKENS_PATH) {
20
+ if (path === TOKENS_PATH && cached) return cached;
21
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
22
+ const resolved = resolve(raw);
23
+ if (path === TOKENS_PATH) cached = resolved;
24
+ return resolved;
25
+ }
26
+
27
+ /**
28
+ * Flatten the file into one lookup of `name -> value`, computing the derived
29
+ * values so that `hair` and `fill_steps` provably come from `ink` rather than
30
+ * being a second place a color is written down.
31
+ */
32
+ /** `$comment` keys are documentation for a human reading tokens.json; they must
33
+ * never reach an emitter's output. */
34
+ const clean = (group) => {
35
+ const out = { ...(group ?? {}) };
36
+ delete out.$comment;
37
+ return out;
38
+ };
39
+
40
+ function resolve(raw) {
41
+ const colors = clean(raw.colors);
42
+ const terminal = clean(raw.terminal);
43
+
44
+ const hairAlpha = raw.derived?.hair_alpha ?? 0.18;
45
+ const [r, g, b] = hexToRgb(colors.ink);
46
+ const hair = `rgba(${r}, ${g}, ${b}, ${hairAlpha})`;
47
+
48
+ const fillSteps = (raw.derived?.fill_steps ?? []).map((name) => {
49
+ const value = colors[name];
50
+ if (!value) throw new Error(`derived.fill_steps names unknown color "${name}"`);
51
+ return value;
52
+ });
53
+
54
+ return {
55
+ name: raw.name,
56
+ schema: raw.schema,
57
+ colors,
58
+ terminal,
59
+ notes: clean(raw.color_notes),
60
+ fonts: clean(raw.fonts),
61
+ identity: clean(raw.identity),
62
+ marks: clean(raw.marks),
63
+ limits: clean(raw.limits),
64
+ derived: { hair, hairAlpha, fillSteps },
65
+ /** Every literal color the brand permits, for the off-palette lint. */
66
+ palette: [...Object.values(colors), ...Object.values(terminal)],
67
+ };
68
+ }
69
+
70
+ export function hexToRgb(hex) {
71
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
72
+ if (!m) throw new Error(`not a 6-digit hex color: ${hex}`);
73
+ const n = parseInt(m[1], 16);
74
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
75
+ }
76
+
77
+ /** Look up a token by its flat name, including the derived ones. */
78
+ export function tokenValue(tokens, name) {
79
+ if (name === 'hair') return tokens.derived.hair;
80
+ if (name in tokens.colors) return tokens.colors[name];
81
+ if (name in tokens.terminal) return tokens.terminal[name];
82
+ if (name in tokens.fonts) return tokens.fonts[name];
83
+ if (name in tokens.identity) return tokens.identity[name];
84
+ if (name in tokens.marks) return tokens.marks[name];
85
+ throw new Error(`unknown token "${name}"`);
86
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@natjswenson/press",
3
+ "version": "0.1.0",
4
+ "description": "One brand system — tokens, laws, run presentation and voice core — generated into every consumer with a CI drift gate",
5
+ "license": "MIT",
6
+ "author": "Nate Swenson",
7
+ "homepage": "https://github.com/natejswenson/claude-skills",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/natejswenson/claude-skills.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/natejswenson/claude-skills/issues"
14
+ },
15
+ "keywords": [
16
+ "press",
17
+ "claude-code",
18
+ "claude-skill",
19
+ "design-system",
20
+ "design-tokens",
21
+ "branding"
22
+ ],
23
+ "type": "module",
24
+ "bin": {
25
+ "press": "bin/press.js"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "lib/",
30
+ "brand/",
31
+ "targets.json",
32
+ "skill-invariants.json",
33
+ "SKILL.md",
34
+ "CHANGELOG.md",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test \"tests/**/*.test.mjs\"",
43
+ "audit": "npm audit --audit-level=moderate",
44
+ "prepack": "cp ../../README.md ../../LICENSE ../../CHANGELOG.md .",
45
+ "postpack": "rm -f README.md LICENSE CHANGELOG.md"
46
+ }
47
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "comment": "Prose guardrails in SKILL.md that must survive edits, plus the baseline eval declaration. Each prose pattern is a case-insensitive regex tested against the full SKILL.md text by tests/skill-contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
3
+ "prose": [
4
+ {
5
+ "id": "never-write-a-value-by-hand",
6
+ "pattern": "Never write a brand value into a file by hand",
7
+ "rationale": "The entire skill exists because copying values by hand produced eight divergent copies. Losing this line reopens exactly the failure mode press was built to close."
8
+ },
9
+ {
10
+ "id": "add-to-tokens-never-invent-locally",
11
+ "pattern": "add it to `brand/tokens\\.json` and re-emit\\. Never invent one locally",
12
+ "rationale": "Three genuinely-shared tokens (ink_faint, the terminal quartet, two paper steps) were found undeclared during the first migration precisely because consumers invented values locally. The escape hatch has to point back to tokens.json."
13
+ },
14
+ {
15
+ "id": "check-fails-on-three-things",
16
+ "pattern": "gone missing.{0,400}resolved \\*\\*zero\\*\\* targets",
17
+ "rationale": "A gate that only detects byte drift silently passes when a region is deleted or when nothing resolved. Those two are the ways this check turns decorative, so they must stay named in the contract."
18
+ },
19
+ {
20
+ "id": "never-generate-a-whole-file",
21
+ "pattern": "Never generate a whole file",
22
+ "rationale": "Whole-file generation clobbered ghostwriter's personal avatar footer once already. Region splicing is the load-bearing design decision, not a style preference."
23
+ },
24
+ {
25
+ "id": "prose-contracts-spliced-not-referenced",
26
+ "pattern": "spliced \\*\\*into\\*\\* a consuming SKILL\\.md",
27
+ "rationale": "A consuming skill is a separately installed plugin and cannot read this skill's files at runtime. If this degrades into 'reference the press docs', the agent-UI and voice contracts silently stop applying."
28
+ },
29
+ {
30
+ "id": "medium-voice-wins-on-conflict",
31
+ "pattern": "wins on conflict",
32
+ "rationale": "ghostwriter's learned voice and devlog's release-note shape are personal, per-medium, and higher-signal than the universal floor. Without this precedence rule, folding voice into press would degrade both."
33
+ },
34
+ {
35
+ "id": "npx-must-pin-latest",
36
+ "pattern": "Always pin `@latest`",
37
+ "rationale": "A bare npx invocation silently prefers a stale global install over the registry. This exact failure cost this repo a release with shipflow; the same trap applies verbatim to press."
38
+ },
39
+ {
40
+ "id": "look-at-the-artifact",
41
+ "pattern": "Re-render one real artifact per affected medium and \\*\\*look at it\\*\\*",
42
+ "rationale": "A token change is the one edit that touches every product at once, and no test asserts taste. The visual confirmation step is the only thing standing between a one-character edit and shipping it everywhere."
43
+ }
44
+ ],
45
+ "baseline": [
46
+ {
47
+ "id": "pre-migration-value-snapshot",
48
+ "kind": "golden",
49
+ "test": "tests/baseline.test.mjs",
50
+ "fixtures": ["tests/fixtures/pre-migration-values.json"],
51
+ "update_command": "node tests/fixtures/update-pre-migration.mjs",
52
+ "rationale": "A frozen snapshot of every brand value as it actually existed in eight files across four repos (claude-skills, budget, local-fitness, natejswenson.io) before press generated any of them. This is the migration's no-op proof: whatever press emits must still carry those values, so a future token edit that silently changes a colour a shipped product depends on goes red instead of quietly re-rendering every artifact wrong. Pinned to a real past state, never synthesised. The paired negative assertion (deleting the accent from the known set must be detected) keeps the check from passing while the comparison rots, and a min_sources floor of 7 stops the fixture from shrinking to nothing and still reporting clean."
53
+ },
54
+ {
55
+ "id": "emitted-region-goldens",
56
+ "kind": "golden",
57
+ "test": "tests/baseline.test.mjs",
58
+ "corpus_glob": "tests/fixtures/golden/*.txt",
59
+ "min_corpus": 8,
60
+ "update_command": "node tests/fixtures/update-pre-migration.mjs",
61
+ "rationale": "Byte-exact expected output for every registered target's region. Byte-exactness is correct here for the same reason it is for shipflow's rendered workflow: the region IS the contract, and one changed character is a change to a shipped artifact's appearance. min_corpus 8 is the anti-vacuity floor -- a glob that quietly matched nothing would otherwise report every target as passing. The paired negative assertion bends one token and requires the comparison to notice."
62
+ },
63
+ {
64
+ "id": "shipped-stylesheets-lint-clean",
65
+ "kind": "corpus",
66
+ "test": "tests/lint.test.mjs",
67
+ "corpus_glob": "../../../*/skills/*/assets/**/press.css",
68
+ "min_corpus": 1,
69
+ "update_command": "node bin/press.js lint <file> --raster",
70
+ "rationale": "The brand lint is run against the stylesheets this repo actually ships, so a rule strict enough to reject work already published fails here rather than in a release. This caught a real over-strict rule on its first run: the 0.10em tracking ceiling exists to protect PDF text extraction, and the ghostwriter card set legitimately runs its eyebrow at .16em because a rasterised card's text is never extracted -- the rule was scoped to extractable documents instead of the corpus being waived. Every rule is two-sided: the real corpus passes and a targeted mutation of it fails."
71
+ }
72
+ ]
73
+ }