@ericrisco/rsc 1.0.34 → 1.1.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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.34",
2
+ "version": "1.1.1",
3
3
  "counts": {
4
4
  "skills": 278
5
5
  },
@@ -1409,7 +1409,9 @@
1409
1409
  "nextjs",
1410
1410
  "marketing"
1411
1411
  ],
1412
- "profiles": []
1412
+ "profiles": [
1413
+ "full"
1414
+ ]
1413
1415
  },
1414
1416
  {
1415
1417
  "id": "design-dna",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.34",
3
+ "version": "1.1.1",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -7,6 +7,8 @@ import { loadManifest } from './lib/manifest.js';
7
7
  import { listBackups } from './lib/backups.js';
8
8
  import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
9
9
  import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
10
+ import { designIdentity } from './lib/design-identity.js';
11
+ import { startingPointSummary } from './lib/starting-point.js';
10
12
  import { countGaps } from './lib/capabilities.js';
11
13
 
12
14
  // The sello's health, surfaced where the user already looks (spec: non-blocking
@@ -57,6 +59,14 @@ export function doctor({ target, home, cwd }) {
57
59
  },
58
60
  contextBudget: contextBudget({ target, home, cwd }),
59
61
  sello: selloStatus(root),
62
+ // Whether this harness has a design identity at all. `design` has always DECLARED that it
63
+ // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
64
+ // never nagged about and never blocking: a missing identity is low risk (P7).
65
+ designIdentity: designIdentity(root),
66
+ // The other half of the same question: an identity is what this project settled on, a starting
67
+ // point is what it has to settle FROM. Three skills promise to propose one and none could look;
68
+ // this is the look. One field, not a section — a report grows for every user who runs it (P5).
69
+ designStartingPoint: startingPointSummary(root),
60
70
  // An inert gate must never read as an armed one (the same rule the sello follows):
61
71
  // the gitmoji guard has a per-project kill switch, so say which state it is in.
62
72
  gitmojiGuard: existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
@@ -0,0 +1,117 @@
1
+ // Does this harness have a design identity, and can we tell?
2
+ //
3
+ // `design` has declared for a long time that without a brand study it STOPS and does not design.
4
+ // Nothing checked it: `grep -rln "wiki/brand" scripts/` returned nothing before this file existed.
5
+ // That is P2 — a rule declared binding with no mechanism is a decorative gate. Spec:
6
+ // 02-DOCS/wiki/sdd/specs/design-area-cleanup.md
7
+ //
8
+ // Three states, and the third is the whole point. A checker that cannot look must never report
9
+ // green: that exact defect was found and fixed in the design-dna style checker two days earlier,
10
+ // where regex probes ran against empty text and returned vacuous passes. Absence of evidence is
11
+ // reported as absence of evidence.
12
+ //
13
+ // Read-only. It never writes: 02-DOCS is untracked (P9) and there is no undo there.
14
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ // Where the harness keeps durable design knowledge, and the section in the root instructions file
18
+ // that points at it. Both are the `design`/`harness` convention, not invented here.
19
+ export const BRAND_DIR = join('02-DOCS', 'wiki', 'brand');
20
+ export const BRAND_SECTION = '## Brand & voice';
21
+
22
+ /**
23
+ * @returns {{state: 'present'|'missing'|'inconclusive', linked: boolean, articles: string[],
24
+ * reason: string, fix?: string}}
25
+ * `present` — articles exist under the brand dir. `linked` says whether the root
26
+ * instructions file points at them, which is how other skills find it.
27
+ * `missing` — looked, found nothing. Carries the fix (P6: a denial carries its way out).
28
+ * `inconclusive` — could not look. Never conflated with `missing`, and never with green.
29
+ */
30
+ export function designIdentity(root = process.cwd()) {
31
+ const dir = join(root, BRAND_DIR);
32
+
33
+ let articles;
34
+ try {
35
+ if (!existsSync(dir)) {
36
+ // existsSync FOLLOWS symlinks, so a dangling one reports false and would be filed as "no
37
+ // identity here". It is not absence, it is unreadability — the same conflation this whole
38
+ // area was cleaning up. lstat sees the link itself and tells the two apart.
39
+ let danglingLink = false;
40
+ try { danglingLink = lstatSync(dir).isSymbolicLink(); } catch { /* genuinely absent */ }
41
+ if (danglingLink) {
42
+ return {
43
+ state: 'inconclusive',
44
+ linked: false,
45
+ articles: [],
46
+ reason: `${BRAND_DIR} is a symlink that does not resolve`,
47
+ };
48
+ }
49
+ return {
50
+ state: 'missing',
51
+ linked: false,
52
+ articles: [],
53
+ reason: `no ${BRAND_DIR}/ in this harness`,
54
+ fix: 'run `design-loop` to converge on a winner, then `design-dna` to write its record here',
55
+ };
56
+ }
57
+ // Not statSync: it follows symlinks, so a symlink here could report on a directory outside the
58
+ // declared zone. Same reason drift-check.js:167 uses dirents.
59
+ if (!statSync(dir).isDirectory()) {
60
+ return {
61
+ state: 'inconclusive',
62
+ linked: false,
63
+ articles: [],
64
+ reason: `${BRAND_DIR} exists but is not a directory`,
65
+ };
66
+ }
67
+ articles = readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'index.md');
68
+ } catch (e) {
69
+ // Unreadable for any reason (permissions, dangling symlink, ELOOP). We did not look, so we
70
+ // cannot say. Reporting `missing` here would invent a finding; reporting green would hide one.
71
+ return {
72
+ state: 'inconclusive',
73
+ linked: false,
74
+ articles: [],
75
+ reason: `cannot read ${BRAND_DIR}: ${e.code || e.message}`,
76
+ };
77
+ }
78
+
79
+ // The link matters on its own: five skills reach the identity through the root instructions file,
80
+ // so an unlinked identity is one they will not find.
81
+ let linked = false;
82
+ const claudeMd = join(root, 'CLAUDE.md');
83
+ if (existsSync(claudeMd)) {
84
+ try {
85
+ linked = readFileSync(claudeMd, 'utf8').includes(BRAND_SECTION);
86
+ } catch {
87
+ // The articles are what we were asked about and we did read them. A CLAUDE.md we could not
88
+ // read makes `linked` unknown, not the whole answer unknown — report it in the reason.
89
+ return {
90
+ state: articles.length ? 'present' : 'missing',
91
+ linked: false,
92
+ articles,
93
+ reason: `${articles.length} article(s), but CLAUDE.md could not be read to confirm the link`,
94
+ };
95
+ }
96
+ }
97
+
98
+ if (!articles.length) {
99
+ return {
100
+ state: 'missing',
101
+ linked,
102
+ articles: [],
103
+ reason: `${BRAND_DIR}/ exists but holds no identity article`,
104
+ fix: 'run `design-loop` to converge on a winner, then `design-dna` to write its record here',
105
+ };
106
+ }
107
+
108
+ return {
109
+ state: 'present',
110
+ linked,
111
+ articles,
112
+ reason: linked
113
+ ? `${articles.length} article(s), linked from CLAUDE.md`
114
+ : `${articles.length} article(s), but no "${BRAND_SECTION}" section in CLAUDE.md points at them`,
115
+ ...(linked ? {} : { fix: `add a "${BRAND_SECTION}" section to CLAUDE.md linking ${BRAND_DIR}/` }),
116
+ };
117
+ }
@@ -0,0 +1,314 @@
1
+ // What does this project already own to start from, and does its colour hold up?
2
+ //
3
+ // The design area declares three times that it will propose a starting point when the user has
4
+ // none — `design-loop` phase 1 on `skip`, the closing line of `design`'s brand grounding, and
5
+ // `design-dna`'s REUSE mode — and none of the three had anything behind it. An unbacked promise to
6
+ // propose falls back to the model's prior, which is the AI-template median the area exists to
7
+ // escape. Spec: 02-DOCS/wiki/sdd/specs/design-starting-point.md
8
+ //
9
+ // This file is the half that can be an algorithm (P1): listing what is installed, and refusing to
10
+ // propose a colour that fails contrast. The half that needs judgement — which reference is the right
11
+ // bar for what you are building — is prose, in design/references/starting-point.md.
12
+ //
13
+ // Read-only, always. `02-DOCS` is untracked and has no undo (P9), and a discovery pass has no
14
+ // business writing anywhere.
15
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { designIdentity, BRAND_DIR } from './design-identity.js';
19
+
20
+ // WCAG 2.2 AA. Body text, and the relaxed floor for large text and UI components.
21
+ export const TEXT_MIN = 4.5;
22
+ export const UI_MIN = 3.0;
23
+
24
+ // Which palette roles are held to which floor. `design-dna`'s schema fixes the role vocabulary
25
+ // (ground, ink, accent, secondary, hairline); anything outside it is skipped rather than guessed at,
26
+ // because inventing a threshold for an unknown role is how a gate starts approving things.
27
+ const TEXT_ROLES = new Set(['ink', 'secondary']);
28
+ const UI_ROLES = new Set(['accent']);
29
+ const GROUND_ROLE = 'ground';
30
+
31
+ // A record is only reusable if it carries the fields a style is actually rebuilt from. Half a record
32
+ // cannot be reused, so it is reported as unreadable instead of offered as a candidate.
33
+ const REQUIRED = ['palette'];
34
+ const DIMENSIONS = ['palette', 'type', 'space', 'surface', 'motion', 'signatures', 'voice'];
35
+
36
+ /** Where `design-dna` emits, and where rsc installs, in both scopes. */
37
+ export function defaultRoots({ home = homedir(), cwd = process.cwd() } = {}) {
38
+ const roots = [];
39
+ for (const base of home === cwd ? [cwd] : [cwd, home]) {
40
+ roots.push(join(base, '.claude', 'skills'));
41
+ roots.push(join(base, '.rsc', 'skills'));
42
+ }
43
+ return roots;
44
+ }
45
+
46
+ function parseHex(hex) {
47
+ if (typeof hex !== 'string') return null;
48
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
49
+ if (!m) return null;
50
+ const n = parseInt(m[1], 16);
51
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
52
+ }
53
+
54
+ // WCAG relative luminance. The sRGB transfer function, not a shortcut: an approximation here is a
55
+ // wrong ratio everywhere downstream, and it would pass a test written against this file's own output.
56
+ function luminance([r, g, b]) {
57
+ const lin = [r, g, b].map((c) => {
58
+ const s = c / 255;
59
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
60
+ });
61
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
62
+ }
63
+
64
+ /**
65
+ * WCAG 2.x contrast ratio between two colours, 1.0 … 21.0. Symmetric.
66
+ * @param {string} a sRGB hex, `#rrggbb`
67
+ * @param {string} b sRGB hex, `#rrggbb`
68
+ */
69
+ export function contrastRatio(a, b) {
70
+ const ca = parseHex(a);
71
+ const cb = parseHex(b);
72
+ if (!ca || !cb) throw new TypeError(`not an sRGB hex colour: ${!ca ? a : b}`);
73
+ const la = luminance(ca);
74
+ const lb = luminance(cb);
75
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
76
+ return (hi + 0.05) / (lo + 0.05);
77
+ }
78
+
79
+ /**
80
+ * Does this palette's text hold up against its own ground?
81
+ *
82
+ * Runs before a measured colour is written as a proposed dimension: a value that fails is not
83
+ * proposed at all, rather than proposed and flagged. Three outcomes are kept apart on purpose —
84
+ * measured-and-passing, measured-and-failing, and **not measured**. Collapsing the third into the
85
+ * first is the vacuous pass this area has already paid for twice.
86
+ *
87
+ * @param {Array<{role: string, hex: string, name?: string}>} colors — `palette.colors` from a dna.json
88
+ * @returns {{ok: boolean, pairs: Array<object>, failures: Array<object>, skipped: Array<object>,
89
+ * unparsed: string[], reason: string}}
90
+ */
91
+ export function checkPairings(colors) {
92
+ const list = Array.isArray(colors) ? colors : [];
93
+ const pairs = [];
94
+ const skipped = [];
95
+ const unparsed = [];
96
+
97
+ const ground = list.find((c) => c && c.role === GROUND_ROLE);
98
+ const groundHex = ground ? parseHex(ground.hex) : null;
99
+ if (!ground) {
100
+ return { ok: false, pairs, failures: [], skipped, unparsed, reason: 'no ground role in the palette — nothing to measure against' };
101
+ }
102
+ if (!groundHex) {
103
+ unparsed.push(String(ground.hex));
104
+ return { ok: false, pairs, failures: [], skipped, unparsed, reason: `ground colour is not an sRGB hex: ${ground.hex}` };
105
+ }
106
+
107
+ for (const c of list) {
108
+ if (!c || c.role === GROUND_ROLE) continue;
109
+ const isText = TEXT_ROLES.has(c.role);
110
+ const isUi = UI_ROLES.has(c.role);
111
+ if (!isText && !isUi) {
112
+ // A hairline is not text. Saying so is not the same as saying it passed.
113
+ skipped.push({ role: c.role, name: c.name, reason: 'role carries no text, so no text floor applies' });
114
+ continue;
115
+ }
116
+ if (!parseHex(c.hex)) {
117
+ unparsed.push(String(c.hex));
118
+ continue;
119
+ }
120
+ const min = isText ? TEXT_MIN : UI_MIN;
121
+ const ratio = Math.round(contrastRatio(c.hex, ground.hex) * 100) / 100;
122
+ pairs.push({ role: c.role, name: c.name, fg: c.hex, bg: ground.hex, ratio, min, ok: ratio >= min });
123
+ }
124
+
125
+ const failures = pairs.filter((p) => !p.ok);
126
+ // Not measuring anything is not a pass. An empty failure list only means green when at least one
127
+ // pair was actually evaluated and nothing was left unparsed.
128
+ const ok = failures.length === 0 && unparsed.length === 0 && pairs.length > 0;
129
+ const reason = unparsed.length
130
+ ? `${unparsed.length} colour(s) could not be parsed, so they were not measured`
131
+ : pairs.length === 0
132
+ ? 'no text or UI role to measure against the ground'
133
+ : failures.length
134
+ ? `${failures.length} pair(s) below their floor`
135
+ : `${pairs.length} pair(s) measured, all above their floor`;
136
+ return { ok, pairs, failures, skipped, unparsed, reason };
137
+ }
138
+
139
+ function readRecord(dir, slug) {
140
+ const file = join(dir, 'dna.json');
141
+ let raw;
142
+ try {
143
+ raw = readFileSync(file, 'utf8');
144
+ } catch (e) {
145
+ return { error: `cannot read dna.json: ${e.code || e.message}` };
146
+ }
147
+ let dna;
148
+ try {
149
+ dna = JSON.parse(raw);
150
+ } catch (e) {
151
+ return { error: `dna.json is not valid JSON: ${e.message}` };
152
+ }
153
+ const missing = REQUIRED.filter((k) => !dna || !dna[k]);
154
+ if (missing.length) return { error: `record is missing ${missing.join(', ')} — half a record cannot be reused` };
155
+
156
+ return {
157
+ record: {
158
+ slug: (dna.meta && dna.meta.slug) || slug,
159
+ name: (dna.meta && dna.meta.name) || slug,
160
+ path: file,
161
+ covers: DIMENSIONS.filter((d) => dna[d]),
162
+ palette: (dna.palette && dna.palette.colors) || [],
163
+ },
164
+ };
165
+ }
166
+
167
+ // What the harness's own identity articles mention. A record the project actually used outranks one
168
+ // that merely happens to be installed — but the others stay on the list, because choosing for the
169
+ // user is exactly what the spec forbids.
170
+ function citedSlugs(harnessRoot) {
171
+ const dir = join(harnessRoot, BRAND_DIR);
172
+ let text = '';
173
+ try {
174
+ for (const f of readdirSync(dir)) {
175
+ if (!f.endsWith('.md')) continue;
176
+ try { text += readFileSync(join(dir, f), 'utf8').toLowerCase(); } catch { /* one unreadable article is not the answer */ }
177
+ }
178
+ } catch { /* no identity here; designIdentity reports that half */ }
179
+ return text;
180
+ }
181
+
182
+ /**
183
+ * What this project already owns to start a design from.
184
+ *
185
+ * @param {string[]} roots — skill directories to scan. Defaults to both scopes, both layouts.
186
+ * @param {string} harnessRoot — where `02-DOCS/` lives, for the identity half and the ordering.
187
+ * @returns {{state: 'owned'|'none'|'inconclusive', identity: object, records: object[],
188
+ * unreadable: Array<{path: string, reason: string}>, reason: string, fix?: string}}
189
+ * `owned` — an identity or at least one legible record exists.
190
+ * `none` — looked everywhere, found nothing. Carries the way out (P6).
191
+ * `inconclusive` — something could not be read. Never reported as `none`, never as green.
192
+ */
193
+ export function ownedStartingPoints(roots = defaultRoots(), harnessRoot = process.cwd()) {
194
+ const identity = designIdentity(harnessRoot);
195
+ const records = [];
196
+ const unreadable = [];
197
+ let blind = 0;
198
+
199
+ for (const root of roots) {
200
+ // existsSync follows symlinks, so a dangling one answers false and would be filed as "nothing
201
+ // installed here". That is unreadability, not absence — the exact conflation designIdentity was
202
+ // fixed for. lstat sees the link itself and tells them apart.
203
+ if (!existsSync(root)) {
204
+ let dangling = false;
205
+ try { dangling = lstatSync(root).isSymbolicLink(); } catch { /* genuinely absent */ }
206
+ if (dangling) {
207
+ blind += 1;
208
+ unreadable.push({ path: root, reason: 'skills root is a symlink that does not resolve' });
209
+ }
210
+ continue;
211
+ }
212
+ let entries;
213
+ try {
214
+ if (!statSync(root).isDirectory()) {
215
+ blind += 1;
216
+ unreadable.push({ path: root, reason: 'skills root exists but is not a directory' });
217
+ continue;
218
+ }
219
+ entries = readdirSync(root, { withFileTypes: true });
220
+ } catch (e) {
221
+ blind += 1;
222
+ unreadable.push({ path: root, reason: `cannot read skills root: ${e.code || e.message}` });
223
+ continue;
224
+ }
225
+ for (const entry of entries) {
226
+ // Dirents, not statSync: a symlinked entry could otherwise report on a directory outside the
227
+ // root we were asked to scan.
228
+ if (!entry.isDirectory()) continue;
229
+ const dir = join(root, entry.name);
230
+ if (!existsSync(join(dir, 'dna.json'))) continue;
231
+ const { record, error } = readRecord(dir, entry.name);
232
+ if (error) { unreadable.push({ path: join(dir, 'dna.json'), reason: error }); continue; }
233
+ if (!records.some((r) => r.slug === record.slug)) records.push(record);
234
+ }
235
+ }
236
+
237
+ const cited = citedSlugs(harnessRoot);
238
+ records.sort((a, b) => {
239
+ const ac = cited.includes(a.slug.toLowerCase()) ? 0 : 1;
240
+ const bc = cited.includes(b.slug.toLowerCase()) ? 0 : 1;
241
+ return ac - bc || a.slug.localeCompare(b.slug);
242
+ });
243
+
244
+ if (records.length || identity.state === 'present') {
245
+ return {
246
+ state: 'owned',
247
+ identity,
248
+ records,
249
+ unreadable,
250
+ reason: [
251
+ identity.state === 'present' ? `identity present (${identity.articles.length} article(s))` : 'no harness identity',
252
+ `${records.length} style record(s)`,
253
+ unreadable.length ? `${unreadable.length} unreadable` : null,
254
+ ].filter(Boolean).join(', '),
255
+ };
256
+ }
257
+
258
+ // Something was there and we could not use it. Saying "nothing to propose" would be a finding we
259
+ // did not earn, and saying nothing at all would hide it.
260
+ if (unreadable.length || blind || identity.state === 'inconclusive') {
261
+ return {
262
+ state: 'inconclusive',
263
+ identity,
264
+ records,
265
+ unreadable,
266
+ reason: unreadable.length
267
+ ? `could not read ${unreadable.length} candidate(s): ${unreadable[0].reason}`
268
+ : `identity could not be read: ${identity.reason}`,
269
+ fix: 'name what could not be read, and ask for another route to it — never propose past a blind spot',
270
+ };
271
+ }
272
+
273
+ return {
274
+ state: 'none',
275
+ identity,
276
+ records: [],
277
+ unreadable,
278
+ reason: 'no harness identity and no style record installed in any scope',
279
+ fix: 'propose at most three openable references as candidate bars (design/references/starting-point.md), then run `design-loop`',
280
+ };
281
+ }
282
+
283
+ /**
284
+ * The compact projection `doctor` reports: one object, not a section (P5 — every byte of a report is
285
+ * paid by every user who runs it). Contrast is folded in per record because a record whose own
286
+ * palette fails is not a starting point anyone should be offered.
287
+ *
288
+ * @returns {{state: string, records: string[], contrast: string, unreadable: number, reason: string}}
289
+ */
290
+ export function startingPointSummary(root = process.cwd(), roots = defaultRoots({ cwd: root })) {
291
+ const r = ownedStartingPoints(roots, root);
292
+ let failing = 0;
293
+ let unmeasured = 0;
294
+ for (const rec of r.records) {
295
+ const c = checkPairings(rec.palette);
296
+ if (c.failures.length) failing += 1;
297
+ else if (!c.ok) unmeasured += 1;
298
+ }
299
+ const contrast = r.records.length === 0
300
+ ? 'no record to measure'
301
+ : failing
302
+ ? `${failing} record(s) with a pair below its floor`
303
+ : unmeasured
304
+ ? `${unmeasured} record(s) could not be measured`
305
+ : 'all records above their floors';
306
+ return {
307
+ state: r.state,
308
+ records: r.records.map((x) => x.slug),
309
+ contrast,
310
+ unreadable: r.unreadable.length,
311
+ reason: r.reason,
312
+ ...(r.fix ? { fix: r.fix } : {}),
313
+ };
314
+ }