@ericrisco/rsc 1.1.4 → 1.1.6

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.
Files changed (66) hide show
  1. package/README.md +81 -11
  2. package/manifest.json +56 -231
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +68 -1
  5. package/scripts/install-apply.js +91 -16
  6. package/scripts/lib/divergence.js +33 -0
  7. package/scripts/lib/domains.js +3 -3
  8. package/scripts/lib/manifest-file.js +53 -0
  9. package/scripts/lib/repair.js +156 -0
  10. package/scripts/lib/ui.js +5 -0
  11. package/scripts/rsc.js +84 -11
  12. package/skills/design/SKILL.md +3 -3
  13. package/skills/design/references/design-systems.md +1 -1
  14. package/skills/design/references/inspiration-sources.md +2 -2
  15. package/skills/design/references/motion-and-interaction.md +2 -2
  16. package/skills/design-dna/evals/cases.yaml +1 -1
  17. package/skills/design-loop/SKILL.md +3 -3
  18. package/skills/design-loop/evals/cases.yaml +3 -3
  19. package/skills/expo/SKILL.md +1 -1
  20. package/skills/motion-craft/SKILL.md +125 -0
  21. package/skills/motion-craft/evals/cases.yaml +38 -0
  22. package/skills/motion-craft/scripts/verify.sh +75 -0
  23. package/skills/react/SKILL.md +2 -2
  24. package/skills/react-native/SKILL.md +1 -1
  25. package/skills/suggest/SKILL.md +25 -1
  26. package/skills/swift-ios/SKILL.md +2 -2
  27. package/skills/ui-engineering/SKILL.md +114 -0
  28. package/skills/ui-engineering/evals/cases.yaml +38 -0
  29. package/skills/variant-explorer/SKILL.md +93 -0
  30. package/skills/variant-explorer/evals/cases.yaml +38 -0
  31. package/targets/_md-block.js +17 -4
  32. package/targets/claude.js +17 -7
  33. package/targets/index.js +66 -19
  34. package/skills/animate/SKILL.md +0 -851
  35. package/skills/animate/evals/cases.yaml +0 -36
  36. package/skills/animate/references/recipes.md +0 -324
  37. package/skills/animate-expo/SKILL.md +0 -281
  38. package/skills/animate-expo/evals/cases.yaml +0 -36
  39. package/skills/animate-expo/references/recipes.md +0 -385
  40. package/skills/animation-vocabulary/SKILL.md +0 -189
  41. package/skills/animation-vocabulary/evals/cases.yaml +0 -31
  42. package/skills/apple-design/SKILL.md +0 -301
  43. package/skills/apple-design/evals/cases.yaml +0 -33
  44. package/skills/ask-sonner/SKILL.md +0 -104
  45. package/skills/ask-sonner/evals/cases.yaml +0 -33
  46. package/skills/ask-sonner/references/api.md +0 -64
  47. package/skills/design-eng/SKILL.md +0 -88
  48. package/skills/design-eng/evals/cases.yaml +0 -36
  49. package/skills/find-animation-opportunities/SKILL.md +0 -150
  50. package/skills/find-animation-opportunities/evals/cases.yaml +0 -31
  51. package/skills/improve-animations/SKILL.md +0 -126
  52. package/skills/improve-animations/evals/cases.yaml +0 -31
  53. package/skills/improve-animations/references/audit.md +0 -115
  54. package/skills/improve-animations/references/plan-template.md +0 -73
  55. package/skills/pick-ui-library/SKILL.md +0 -101
  56. package/skills/pick-ui-library/evals/cases.yaml +0 -33
  57. package/skills/pick-ui-library/references/component-galleries.md +0 -59
  58. package/skills/prototype/SKILL.md +0 -113
  59. package/skills/prototype/evals/cases.yaml +0 -31
  60. package/skills/prototype/references/picker.md +0 -197
  61. package/skills/review-animations/SKILL.md +0 -188
  62. package/skills/review-animations/evals/cases.yaml +0 -34
  63. package/skills/review-animations/references/standards.md +0 -187
  64. package/skills/review-animations/scripts/verify.sh +0 -164
  65. package/skills/write-swift/SKILL.md +0 -413
  66. package/skills/write-swift/evals/cases.yaml +0 -36
@@ -0,0 +1,38 @@
1
+ skill: variant-explorer
2
+
3
+ should_trigger:
4
+ - prompt: "Show me a few versions of this onboarding and I'll pick one."
5
+ why: "The canonical phrasing: options to choose from, not an answer."
6
+ - prompt: "Give me three directions for how this could work before we commit."
7
+ why: "Explicit divergence ahead of an expensive decision."
8
+ - prompt: "Enséñame varias opciones de cómo montar el checkout, que ya elijo yo."
9
+ why: "Same request in Spanish; the trigger is the ask for options."
10
+ - prompt: "I can't decide between a wizard and a single page. Can I see both?"
11
+ why: "Two named approaches where seeing them is what settles it."
12
+ - prompt: "What are some different ways we could structure this dashboard?"
13
+ why: "Open-ended exploration of structural decisions, which is exactly divergence."
14
+ - prompt: "Before we build it, I want to compare a couple of approaches side by side."
15
+ why: "Comparability is the property this skill guarantees."
16
+
17
+ should_not_trigger:
18
+ - prompt: "Review this checkout screen and grade it."
19
+ route_to: "design-loop"
20
+ why: "Judging one finished thing is critique, the opposite of divergence."
21
+ - prompt: "We need to agree what this feature is for before anyone builds it."
22
+ route_to: "specify"
23
+ why: "A missing WHAT/WHY is a spec problem; options would be premature."
24
+ - prompt: "Build the pricing page we agreed on last week."
25
+ route_to: "design"
26
+ why: "The decision is already made; manufacturing options would waste the time this skill is meant to save."
27
+ - prompt: "Which colour should the primary button be?"
28
+ route_to: "design"
29
+ why: "Cheap and reversible, and a detail rather than a decision — the explicit non-case here."
30
+ - prompt: "Run an A/B test to see which headline converts better."
31
+ route_to: "ab-testing"
32
+ why: "Measuring a winner with users is experimentation, not showing a human candidates to choose from."
33
+
34
+ capability:
35
+ - prompt: "Give me three options for this signup flow. Make sure they are actually different, not the same thing restyled."
36
+ why: "Goes straight at the failure mode this skill exists to prevent, and demands the decision behind each be nameable."
37
+ - prompt: "Here are three variants I made. Are these real alternatives or the same idea three times?"
38
+ why: "Applies the one rule as a test on someone else's set, which is where it is hardest and most useful."
@@ -9,6 +9,9 @@ import { linkOrCopy } from './index.js';
9
9
  // multiple assistants sharing one file never duplicate it.
10
10
  const MARK_START = '<!-- rsc-suggest:start -->';
11
11
  const MARK_END = '<!-- rsc-suggest:end -->';
12
+ // The exact separator wireHook writes before an appended block, so unwireHook can take
13
+ // back precisely what it gave and leave the rest of the file untouched.
14
+ const SEAM = '\n\n';
12
15
 
13
16
  export function writeSkill(id, fromDir, toPath) {
14
17
  return linkOrCopy(fromDir, toPath);
@@ -21,7 +24,9 @@ export function wireHook(paths, sourceMd) {
21
24
  if (doc.includes(MARK_START)) {
22
25
  doc = doc.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`), block);
23
26
  } else {
24
- doc += `\n\n${block}\n`;
27
+ // Append with a fixed seam, and record nothing else. unwireHook removes exactly this
28
+ // seam and this block, which is what lets the file come back byte-identical.
29
+ doc += `${SEAM}${block}\n`;
25
30
  }
26
31
  mkdirSync(dirname(paths.hookTarget), { recursive: true });
27
32
  writeFileSync(paths.hookTarget, doc);
@@ -34,9 +39,17 @@ export function unwireHook(paths) {
34
39
  if (!existsSync(paths.hookTarget)) return [];
35
40
  const doc = readFileSync(paths.hookTarget, 'utf8');
36
41
  if (!doc.includes(MARK_START)) return [];
37
- const cleaned = doc
38
- .replace(new RegExp(`\\n*${MARK_START}[\\s\\S]*?${MARK_END}\\n*`), '\n')
39
- .replace(/\n{3,}/g, '\n\n');
42
+ // Remove the block and the seam that introduced it — nothing more.
43
+ //
44
+ // This used to end with a document-wide `\n{3,}` -> `\n\n` pass, which tidied blank
45
+ // lines the author had put hundreds of lines away from anything of ours. In #249 that
46
+ // file was the project's hand-written constitution: it had already received 95 lines it
47
+ // never asked for, and giving it back reformatted is the second half of the same damage.
48
+ // A slightly larger gap is cosmetic; rewriting someone's document is not.
49
+ const cleaned = doc.replace(
50
+ new RegExp(`${SEAM.replace(/\n/g, '\\n')}?${MARK_START}[\\s\\S]*?${MARK_END}\\n?`),
51
+ '',
52
+ );
40
53
  writeFileSync(paths.hookTarget, cleaned);
41
54
  return [paths.hookTarget];
42
55
  }
package/targets/claude.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { linkOrCopy } from './index.js';
5
5
 
@@ -70,6 +70,15 @@ export function unwireHook(paths) {
70
70
  // entry (the old `cat …/suggest/SKILL.md` form, or a previous `.sh`/`.mjs` script
71
71
  // form) is dropped before we add the current one — idempotent, migrating legacy and
72
72
  // bash-era hooks in place. Other (non-rsc) SessionStart hooks are preserved.
73
+ // Commands name the project through ${CLAUDE_PROJECT_DIR}, never through the absolute
74
+ // path of the folder it happened to be installed in. Baking that path in is what made a
75
+ // harness unshareable: a committed settings.json was broken in every other checkout, and
76
+ // even renaming your own folder broke it. The variable is resolved by the client at run
77
+ // time, so the same bytes work on every machine — which is the precondition for the
78
+ // manifest being worth committing at all.
79
+ const P = '${CLAUDE_PROJECT_DIR}';
80
+ const at = (...seg) => `${P}/${seg.join('/')}`;
81
+
73
82
  export function wireHook(paths) {
74
83
  const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
75
84
  mkdirSync(dirname(scriptDest), { recursive: true });
@@ -79,7 +88,8 @@ export function wireHook(paths) {
79
88
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
80
89
 
81
90
  const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
82
- const cmd = `node "${scriptDest}" "${suggestMd}" "${paths.projectRoot}"`;
91
+ const suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
92
+ const cmd = `node "${at('.rsc', 'session-start.mjs')}" "${suggestRel}" "${P}"`;
83
93
 
84
94
  const file = paths.hookTarget;
85
95
  const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
@@ -99,7 +109,7 @@ export function wireHook(paths) {
99
109
  // any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
100
110
  const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
101
111
  copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
102
- const wlCmd = `node "${wlDest}" "${paths.projectRoot}"`;
112
+ const wlCmd = `node "${at('.rsc', 'worklog-checkpoint.mjs')}" "${P}"`;
103
113
  for (const event of ['PreCompact', 'SessionEnd']) {
104
114
  settings.hooks[event] ||= [];
105
115
  settings.hooks[event] = settings.hooks[event].filter(
@@ -118,7 +128,7 @@ export function wireHook(paths) {
118
128
  // sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
119
129
  // so the deterministic sello core must land next to the guard that loads it).
120
130
  copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
121
- const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
131
+ const sgCmd = `node "${at('.rsc', 'ship-guard.mjs')}" "${P}"`;
122
132
  settings.hooks.PreToolUse ||= [];
123
133
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
124
134
  (e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
@@ -132,7 +142,7 @@ export function wireHook(paths) {
132
142
  // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
133
143
  const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
134
144
  copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
135
- const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
145
+ const dgCmd = `node "${at('.rsc', 'danger-guard.mjs')}" "${P}"`;
136
146
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
137
147
  (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
138
148
  );
@@ -147,7 +157,7 @@ export function wireHook(paths) {
147
157
  // .rsc/.no-gitmoji.
148
158
  const gmDest = join(paths.projectRoot, '.rsc', 'gitmoji-guard.mjs');
149
159
  copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
150
- const gmCmd = `node "${gmDest}" "${paths.projectRoot}"`;
160
+ const gmCmd = `node "${at('.rsc', 'gitmoji-guard.mjs')}" "${P}"`;
151
161
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
152
162
  (e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
153
163
  );
@@ -163,7 +173,7 @@ export function wireHook(paths) {
163
173
  // (non-rsc) UserPromptSubmit hooks are preserved.
164
174
  const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
165
175
  copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
166
- const fgCmd = `node "${fgDest}" "${paths.projectRoot}"`;
176
+ const fgCmd = `node "${at('.rsc', 'userprompt-gate.mjs')}" "${P}"`;
167
177
  settings.hooks.UserPromptSubmit ||= [];
168
178
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
169
179
  (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
package/targets/index.js CHANGED
@@ -1,9 +1,10 @@
1
- import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync, readFileSync } from 'node:fs';
2
2
  import { join, dirname, relative } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import * as claudeAdapter from './claude.js';
5
5
  import * as cursorAdapter from './cursor.js';
6
6
  import * as mdAdapter from './_md-block.js';
7
+ import { readManifest } from '../scripts/lib/manifest-file.js';
7
8
 
8
9
  // Project-local single source of truth. Real skill files live here exactly once;
9
10
  // every assistant gets a lightweight pointer (symlink) back to it — no duplication.
@@ -83,28 +84,74 @@ export const TARGETS = [
83
84
  { id: 'aider', label: 'Aider', hint: 'CONVENTIONS.md' },
84
85
  ];
85
86
 
86
- // Best-effort default for the wizard's pre-mark. Unique config dirs win; the
87
- // shared AGENTS.md / GEMINI.md fall through to codex / gemini.
87
+ // Which assistants are actually installed here, read from the state file each one
88
+ // writes. This is evidence: only an install of ours writes it. Everything below
89
+ // (detectTarget) is inference from files a human — or our own `harness` skill —
90
+ // may have written for unrelated reasons. Order follows SPEC so the ambiguity
91
+ // message is deterministic. A missing, empty or unreadable state means "not
92
+ // installed", never an error: a half-written file must not break resolution.
93
+ export function installedTargets(cwd = process.cwd()) {
94
+ return TARGET_IDS.filter((id) => {
95
+ try {
96
+ const state = JSON.parse(readFileSync(targetPaths(id, undefined, cwd).stateFile, 'utf8'));
97
+ return Object.keys(state.skills || {}).length > 0;
98
+ } catch { return false; }
99
+ });
100
+ }
101
+
102
+ // Inference, used only when there is no evidence. Unique config dirs win.
103
+ //
104
+ // Two rules here are scar tissue from issue #249. Claude Code had NO signal at
105
+ // all — it was merely the final fallthrough — so any repo with a root AGENTS.md
106
+ // resolved to codex even with .claude/ full of skills. And AGENTS.md is the
107
+ // weakest signal on purpose: our own `harness` skill writes one into every repo
108
+ // it equips, so treating it as a strong hint made the harness poison its own
109
+ // detection the moment it ran once.
88
110
  export function detectTarget(cwd = process.cwd()) {
89
- const dir = (d) => existsSync(join(cwd, d));
90
- if (dir('.cursor')) return 'cursor';
91
- if (dir('.windsurf')) return 'windsurf';
92
- if (dir('.clinerules')) return 'cline';
93
- if (dir('.roo')) return 'roo';
94
- if (dir('.continue')) return 'continue';
95
- if (dir('.junie')) return 'junie';
96
- if (dir('.kiro')) return 'kiro';
97
- if (dir('.zed')) return 'zed';
98
- if (dir('.opencode')) return 'opencode';
99
- if (dir('.amp')) return 'amp';
100
- if (dir('.jules')) return 'jules';
101
- if (dir('.antigravity')) return 'antigravity';
102
- if (existsSync(join(cwd, '.github', 'copilot-instructions.md'))) return 'copilot';
103
- if (dir('.codex') || dir('AGENTS.md')) return 'codex';
104
- if (dir('.gemini') || dir('GEMINI.md')) return 'gemini';
111
+ const has = (p) => existsSync(join(cwd, p));
112
+ if (has('.cursor')) return 'cursor';
113
+ if (has('.windsurf')) return 'windsurf';
114
+ if (has('.clinerules')) return 'cline';
115
+ if (has('.roo')) return 'roo';
116
+ if (has('.continue')) return 'continue';
117
+ if (has('.junie')) return 'junie';
118
+ if (has('.kiro')) return 'kiro';
119
+ if (has('.zed')) return 'zed';
120
+ if (has('.opencode')) return 'opencode';
121
+ if (has('.amp')) return 'amp';
122
+ if (has('.jules')) return 'jules';
123
+ if (has('.antigravity')) return 'antigravity';
124
+ if (has(join('.github', 'copilot-instructions.md'))) return 'copilot';
125
+ if (has('.claude') || has('CLAUDE.md')) return 'claude';
126
+ if (has('.codex')) return 'codex';
127
+ if (has('.gemini') || has('GEMINI.md')) return 'gemini';
128
+ if (has('AGENTS.md')) return 'codex'; // weakest: our own harness writes this
105
129
  return 'claude';
106
130
  }
107
131
 
132
+ // The single point of truth for "which assistant are we acting on". Every command
133
+ // goes through here — including doctor, which used to resolve on its own and so
134
+ // could report a different target than the one just installed into.
135
+ //
136
+ // Ambiguity is a distinct outcome, not a value the caller might mistake for an id:
137
+ // returning a sentinel string would let a forgetful caller pass it to targetPaths()
138
+ // and die with `unknown target`. `ids` empty + `ambiguous` set cannot be used by
139
+ // accident.
140
+ export function resolveTargets({ cwd = process.cwd(), flagValue } = {}) {
141
+ if (typeof flagValue === 'string' && flagValue.trim()) {
142
+ const ids = flagValue.split(',').map((s) => s.trim()).filter(Boolean);
143
+ return { ids, ambiguous: null, source: 'flag' };
144
+ }
145
+ // The committed manifest is what the team decided, so it outranks what happens to be
146
+ // on this machine. Two targets here is not ambiguity — it is the team saying "both".
147
+ const declared = readManifest(cwd)?.targets || [];
148
+ if (declared.length) return { ids: declared, ambiguous: null, source: 'manifest' };
149
+ const found = installedTargets(cwd);
150
+ if (found.length === 1) return { ids: found, ambiguous: null, source: 'evidence' };
151
+ if (found.length > 1) return { ids: [], ambiguous: found, source: 'evidence' };
152
+ return { ids: [detectTarget(cwd)], ambiguous: null, source: 'heuristic' };
153
+ }
154
+
108
155
  export function targetPaths(target, home = homedir(), cwd = process.cwd()) {
109
156
  const s = SPEC[target];
110
157
  if (!s) throw new Error(`unknown target ${target}`);