@ericrisco/rsc 2.0.20 → 2.2.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.
Files changed (97) hide show
  1. package/README.md +63 -21
  2. package/manifest.json +18 -85
  3. package/package.json +5 -1
  4. package/scripts/consult.js +8 -8
  5. package/scripts/install-apply.js +48 -11
  6. package/scripts/lib/default-skill-floor.js +5 -4
  7. package/scripts/lib/divergence.js +7 -3
  8. package/scripts/lib/domains.js +2 -2
  9. package/scripts/lib/onboarding-apply.js +43 -26
  10. package/scripts/lib/onboarding.js +5 -5
  11. package/scripts/lib/recommend.js +1 -1
  12. package/scripts/lib/repair.js +4 -2
  13. package/scripts/lib/retired-skills.js +20 -0
  14. package/scripts/rsc.js +25 -19
  15. package/scripts/skill-behavior-rubric.md +1 -1
  16. package/scripts/skill-migration-brief.md +3 -3
  17. package/skills/analyze/SKILL.md +3 -8
  18. package/skills/analyze/evals/cases.yaml +1 -1
  19. package/skills/author-skill/SKILL.md +3 -3
  20. package/skills/author-skill/evals/cases.yaml +1 -1
  21. package/skills/author-skill/references/rsc-conventions.md +1 -1
  22. package/skills/clarify/SKILL.md +7 -14
  23. package/skills/clarify/evals/cases.yaml +2 -2
  24. package/skills/code-review/SKILL.md +1 -1
  25. package/skills/constitution/SKILL.md +10 -13
  26. package/skills/constitution/evals/cases.yaml +3 -3
  27. package/skills/continuous-learning/SKILL.md +1 -1
  28. package/skills/continuous-learning/evals/cases.yaml +1 -1
  29. package/skills/debug/SKILL.md +8 -12
  30. package/skills/debug/evals/cases.yaml +1 -1
  31. package/skills/decision-challenge/SKILL.md +1 -1
  32. package/skills/deprecation/SKILL.md +1 -1
  33. package/skills/ftd/SKILL.md +1 -3
  34. package/skills/ftd/evals/cases.yaml +1 -1
  35. package/skills/harness/SKILL.md +10 -9
  36. package/skills/harness/references/claude-md-template.md +1 -1
  37. package/skills/harness/references/wiki-protocol.md +5 -6
  38. package/skills/idea-refinement/SKILL.md +1 -1
  39. package/skills/implement/SKILL.md +14 -18
  40. package/skills/implement/evals/cases.yaml +1 -1
  41. package/skills/init/SKILL.md +36 -45
  42. package/skills/init/evals/README.md +3 -3
  43. package/skills/init/evals/cases.yaml +7 -7
  44. package/skills/init/references/accompaniment-and-profile.md +22 -40
  45. package/skills/init/references/discovery.md +2 -2
  46. package/skills/init/references/recommend-skills.md +5 -5
  47. package/skills/init/scripts/verify.sh +3 -7
  48. package/skills/knowledge-ops/SKILL.md +1 -1
  49. package/skills/orient/SKILL.md +52 -41
  50. package/skills/orient/evals/README.md +11 -9
  51. package/skills/orient/evals/cases.yaml +39 -29
  52. package/skills/orient/references/explain-ladder.md +93 -0
  53. package/skills/orient/references/orientation-contract.md +70 -18
  54. package/skills/parallel/SKILL.md +1 -1
  55. package/skills/parallel/evals/cases.yaml +2 -2
  56. package/skills/plan/SKILL.md +9 -13
  57. package/skills/review/SKILL.md +5 -8
  58. package/skills/roast-me/SKILL.md +1 -1
  59. package/skills/sdd/SKILL.md +10 -14
  60. package/skills/sdd/evals/README.md +2 -2
  61. package/skills/sdd/evals/cases.yaml +4 -4
  62. package/skills/sdd/references/model-routing.md +5 -12
  63. package/skills/sdd-init/SKILL.md +3 -3
  64. package/skills/ship/SKILL.md +6 -11
  65. package/skills/simplify-code/SKILL.md +1 -1
  66. package/skills/skill-scout/SKILL.md +1 -1
  67. package/skills/source-grounded-development/SKILL.md +1 -1
  68. package/skills/specify/SKILL.md +13 -14
  69. package/skills/specify/evals/cases.yaml +1 -1
  70. package/skills/specify/references/eliciting-requirements.md +5 -5
  71. package/skills/suggest/SKILL.md +3 -9
  72. package/skills/suggest/evals/cases.yaml +1 -1
  73. package/skills/tasks/SKILL.md +6 -7
  74. package/skills/tasks/evals/cases.yaml +1 -1
  75. package/skills/unslop/SKILL.md +39 -10
  76. package/skills/unslop/evals/README.md +7 -7
  77. package/skills/unslop/evals/cases.yaml +10 -9
  78. package/skills/verify/SKILL.md +5 -8
  79. package/skills/verify/evals/cases.yaml +1 -1
  80. package/skills/worktrees/SKILL.md +14 -18
  81. package/skills/worktrees/evals/cases.yaml +1 -1
  82. package/targets/_md-block.js +7 -1
  83. package/targets/auto-update.mjs +200 -0
  84. package/targets/claude.js +2 -0
  85. package/targets/clone-bootstrap.mjs +5 -2
  86. package/targets/opt-outs.js +1 -0
  87. package/targets/session-start.mjs +5 -78
  88. package/targets/update-wiring.js +153 -0
  89. package/skills/bro/SKILL.md +0 -76
  90. package/skills/bro/evals/README.md +0 -22
  91. package/skills/bro/evals/cases.yaml +0 -62
  92. package/skills/eli5/SKILL.md +0 -63
  93. package/skills/eli5/evals/README.md +0 -22
  94. package/skills/eli5/evals/cases.yaml +0 -54
  95. package/skills/show-me/SKILL.md +0 -140
  96. package/skills/show-me/evals/README.md +0 -23
  97. package/skills/show-me/evals/cases.yaml +0 -61
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // rsc update check, shared by every assistant that can run it. Standalone (Node built-ins only),
3
+ // copied to `.rsc/auto-update.mjs` at install, like the other hooks.
4
+ //
5
+ // Three ways in, one rule:
6
+ // - Claude Code: `session-start.mjs` imports `updateNotice` and prints it with the always-on body.
7
+ // - Codex, Gemini CLI, Cursor: a session-start hook runs `node .rsc/auto-update.mjs hook <target>`
8
+ // and gets the notice back in that assistant's own output shape.
9
+ // - OpenCode: its plugin imports `updateNotice` and hands the notice to the model.
10
+ // - Any other assistant has no hook. The always-on body asks the agent to run
11
+ // `node .rsc/auto-update.mjs` on its first turn, and to relay what it prints.
12
+ import { readFileSync, existsSync, writeFileSync, openSync } from 'node:fs';
13
+ import { spawn } from 'node:child_process';
14
+ import { join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ // Update check: compare the installed version (.rsc/.version, written at install)
18
+ // against the latest published on npm, and nudge the agent to offer an update.
19
+ // Fail-silent (offline / missing baseline / parse error → nothing). Disable with
20
+ // RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
21
+ function isNewer(a, b) {
22
+ const pa = String(a).split('.').map(Number);
23
+ const pb = String(b).split('.').map(Number);
24
+ for (let i = 0; i < 3; i++) {
25
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
26
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ // "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
32
+ // notice on every session, and two people in two days reported bugs that were already fixed. A
33
+ // version number names nothing anyone recognises. A symptom does: someone who sees a red error after
34
+ // every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
35
+ // is wrong as a symptom, not as a cause).
36
+ //
37
+ // The symptoms travel for free: the registry's `/latest` document is the published package.json, so
38
+ // its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
39
+ // It is text a model will read, so every entry is reduced to one short plain line or dropped.
40
+ const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
41
+ function fixesSince(list, installed, latest) {
42
+ if (!Array.isArray(list)) return [];
43
+ const out = [];
44
+ for (const item of list) {
45
+ if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
46
+ if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
47
+ if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
48
+ const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
49
+ if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
50
+ }
51
+ return out.slice(0, 8);
52
+ }
53
+
54
+ // Auto-update. Asking was not enough: the notice depended on an agent relaying it, and people kept
55
+ // running bugs that were already fixed. So a release in the SAME major installs itself; a new major
56
+ // can change how the harness works, and still asks. On by default; `.rsc/.no-auto-update` (a project
57
+ // decision, travels in `optOuts`) brings back the question for every release.
58
+ //
59
+ // It runs in the background because `npx` can take longer than a hook may, and it changes nothing in
60
+ // this session anyway: the skills are already loaded. The version is the exact one seen here, never
61
+ // `@latest` — what is published in between does not slip in. One attempt per version: a failure falls
62
+ // back to asking, and is retried at most once a day.
63
+ const stateFile = (root) => join(root, '.rsc', 'auto-update.json');
64
+ const logFile = (root) => join(root, '.rsc', 'auto-update.log');
65
+ const AUTO_RUNNING_MS = 15 * 60 * 1000;
66
+ const AUTO_RETRY_MS = 24 * 60 * 60 * 1000;
67
+ const majorOf = (v) => Number(String(v).split('.')[0]) || 0;
68
+
69
+ function readAutoState(root) {
70
+ try { return JSON.parse(readFileSync(stateFile(root), 'utf8')); } catch { return null; }
71
+ }
72
+ function writeAutoState(root, state) {
73
+ try { writeFileSync(stateFile(root), `${JSON.stringify(state, null, 2)}\n`); } catch { /* best effort */ }
74
+ }
75
+ function declaredTargets(root) {
76
+ try {
77
+ const list = JSON.parse(readFileSync(join(root, '.rsc.json'), 'utf8')).targets;
78
+ return Array.isArray(list) ? list.filter((t) => typeof t === 'string' && /^[a-z0-9-]+$/.test(t)) : [];
79
+ } catch { return []; }
80
+ }
81
+ function launchUpdate(root, version, env) {
82
+ // RSC_AUTO_UPDATE_CMD (a JSON argv) replaces npx in tests; the arguments after it are the real ones.
83
+ const base = env.RSC_AUTO_UPDATE_CMD
84
+ ? JSON.parse(env.RSC_AUTO_UPDATE_CMD)
85
+ : ['npx', '-y', `@ericrisco/rsc@${version}`];
86
+ const targets = declaredTargets(root);
87
+ const args = [...base.slice(1), 'sync', ...(targets.length ? ['--target', targets.join(',')] : [])];
88
+ const log = openSync(logFile(root), 'w');
89
+ const child = spawn(base[0], args, {
90
+ cwd: root,
91
+ detached: true,
92
+ stdio: ['ignore', log, log],
93
+ windowsHide: true,
94
+ shell: process.platform === 'win32', // npx is npx.cmd there; every argument was validated above
95
+ env: { ...env, RSC_NO_UPDATE_CHECK: '1', RSC_AUTO_UPDATE_TO: version },
96
+ });
97
+ child.on('error', () => {});
98
+ child.unref();
99
+ }
100
+
101
+ export async function updateNotice(root, env = process.env) {
102
+ let out = '';
103
+ const say = (text) => { out += text; };
104
+ try {
105
+ const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
106
+ // A background update that finished since the last session: say so once, then keep quiet.
107
+ const prior = readAutoState(root);
108
+ if (prior && !prior.reported && SEMVER_ONLY.test(String(prior.to)) && !isNewer(prior.to, installed)) {
109
+ say(`
110
+ ===== rsc updated =====
111
+ rsc updated itself in the background, from ${prior.from} to ${installed}. It is active in this session.
112
+ ACTION: tell the user in one line. Files of the harness may show as changed in git.
113
+ =======================
114
+ `);
115
+ writeAutoState(root, { ...prior, reported: true });
116
+ }
117
+ let doc = null;
118
+ if (env.RSC_LATEST_JSON) doc = JSON.parse(env.RSC_LATEST_JSON);
119
+ else if (env.RSC_LATEST) doc = { version: env.RSC_LATEST };
120
+ else {
121
+ const ctrl = new AbortController();
122
+ const timer = setTimeout(() => ctrl.abort(), 1500);
123
+ const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
124
+ clearTimeout(timer);
125
+ doc = await res.json();
126
+ }
127
+ const latest = doc?.version;
128
+ if (installed && SEMVER_ONLY.test(String(latest)) && isNewer(latest, installed)) {
129
+ const newMajor = majorOf(latest) !== majorOf(installed);
130
+ const auto = !existsSync(join(root, '.rsc', '.no-auto-update')) && !newMajor;
131
+ const now = Date.now();
132
+ const since = prior && prior.to === latest ? now - new Date(prior.startedAt).getTime() : Infinity;
133
+ if (auto && since >= AUTO_RETRY_MS) {
134
+ launchUpdate(root, latest, env);
135
+ writeAutoState(root, { from: installed, to: latest, startedAt: new Date(now).toISOString() });
136
+ say(`
137
+ ===== rsc updating =====
138
+ rsc ${latest} is out — you have ${installed}. It is installing itself in the background now and
139
+ takes effect in the next session. Nothing to do.
140
+ ACTION: tell the user in one line, then continue with the task.
141
+ ========================
142
+ `);
143
+ } else if (auto && since < AUTO_RUNNING_MS) {
144
+ say(`
145
+ ===== rsc updating =====
146
+ rsc ${latest} is still installing itself in the background. It takes effect in the next session.
147
+ ========================
148
+ `);
149
+ } else {
150
+ // Asking: auto-update is off here, the release is a new major, or the background attempt failed.
151
+ let why = '';
152
+ if (auto) why = `The automatic update did not finish (log: .rsc/auto-update.log); it retries once a day.\n`;
153
+ else if (newMajor) why = 'It is a new major version: it can change how the harness works, so it asks first.\n';
154
+ let fixes = [];
155
+ try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
156
+ if (fixes.length) {
157
+ say(`
158
+ ===== rsc update available =====
159
+ rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
160
+ ${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
161
+ ${why}ACTION: before starting the task, tell the user in one line that the update fixes what they
162
+ may be seeing, and if they say yes, run it in this project:
163
+ npx @ericrisco/rsc@latest
164
+ ================================
165
+ `);
166
+ } else {
167
+ say(`
168
+ ===== rsc update available =====
169
+ rsc ${latest} is out — you have ${installed}.
170
+ ${why}ACTION: tell the user a new version is available and, if they say yes, run:
171
+ npx @ericrisco/rsc@latest
172
+ (That reinstalls and refreshes the skill content to the latest.)
173
+ ================================
174
+ `);
175
+ }
176
+ }
177
+ }
178
+ } catch { /* offline / no baseline / parse error → stay silent */ }
179
+ return out;
180
+ }
181
+
182
+ // What each assistant's session-start hook accepts. Codex and Gemini take Claude's shape; Cursor has
183
+ // its own field names. Plain text for an agent that runs this by hand.
184
+ export function hookOutput(target, notice) {
185
+ if (!notice) return target === 'agent' ? '' : '{}';
186
+ if (target === 'agent') return notice;
187
+ if (target === 'cursor') return JSON.stringify({ additional_context: notice });
188
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: notice } });
189
+ }
190
+
191
+ // Run as a script: `node auto-update.mjs [hook <target>] [root]`. Never fails a session.
192
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
193
+ const hook = process.argv[2] === 'hook';
194
+ const target = hook ? String(process.argv[3] || 'agent') : 'agent';
195
+ const root = (hook ? process.argv[4] : process.argv[2]) || process.cwd();
196
+ let notice = '';
197
+ try { if (!process.env.RSC_NO_UPDATE_CHECK) notice = (await updateNotice(root)).trim(); } catch { /* fail open */ }
198
+ if (!hook && !notice) notice = 'rsc is up to date.';
199
+ process.stdout.write(`${hookOutput(target, notice)}\n`);
200
+ }
package/targets/claude.js CHANGED
@@ -118,6 +118,8 @@ export function wireHook(paths, sourceMd, policy = {}) {
118
118
  // Same reason: session-start imports the reaper for the landed-worktree sweep, so it travels as a
119
119
  // sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
120
120
  copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
121
+ // session-start imports the shared update check, so it travels as a sibling too.
122
+ copyFileSync(join(HERE, 'auto-update.mjs'), join(paths.projectRoot, '.rsc', 'auto-update.mjs'));
121
123
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
122
124
  // The one harness file that belongs in the committed tree rather than in `.rsc/`: it is the only
123
125
  // thing standing between a clone and seven stack traces, so it has to survive `git clone`.
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
43
43
  * nothing checks is just drift with a head start.
44
44
  */
45
45
  export const PROJECT_OPT_OUTS = [
46
- 'audit', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
46
+ 'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
47
47
  ];
48
48
 
49
49
  /**
@@ -93,7 +93,10 @@ export function readManifest(root) {
93
93
  // accepted, and anything else is simply not there.
94
94
  const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
95
95
  const ids = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && ID.test(x)) : []);
96
- const skills = ids(parsed.skills);
96
+ // Retired skills read as their successors. Canonical map: scripts/lib/retired-skills.js — inlined
97
+ // because this file cannot import anything, and tests/retired-skills.test.js holds the two equal.
98
+ const RETIRED = { eli5: 'orient', 'show-me': 'orient', bro: 'unslop' };
99
+ const skills = [...new Set(ids(parsed.skills).map((id) => (Object.hasOwn(RETIRED, id) ? RETIRED[id] : id)))];
97
100
  const own = ids(parsed.ownSkills);
98
101
  // A manifest with nothing declared is not a clone waiting to be equipped; there is nothing to offer.
99
102
  if (!skills.length && !own.length) return { state: 'absent' };
@@ -18,6 +18,7 @@
18
18
  /** Decisions of the project: they belong in `.rsc.json` and are rebuilt in a clone. */
19
19
  export const PROJECT_OPT_OUTS = [
20
20
  'audit', // cadence of this project's skill audit
21
+ 'auto-update', // installing new rsc releases alone: one machine ahead moves the team's catalogVersion
21
22
  'claudemd-check', // the root CLAUDE.md context budget
22
23
  'danger-guard', // foot-gun denial, keyed to the committed user profile
23
24
  'feature-gate', // per-turn re-injection of the SDD gate
@@ -6,6 +6,7 @@
6
6
  // workspace has no harness profile yet, and an auto-ingest nudge when there is
7
7
  // un-ingested material waiting in the inbox.
8
8
  import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { updateNotice } from './auto-update.mjs';
9
10
  import { join, resolve } from 'node:path';
10
11
  import { homedir } from 'node:os';
11
12
  import { claimOnce, readHookInput } from './hook-once.mjs';
@@ -120,7 +121,7 @@ if (!existsSync(profile) && !existsSync(optout)) {
120
121
  process.stdout.write(`
121
122
  ===== rsc onboarding =====
122
123
  Fresh setup: 02-DOCS/wiki/harness/user-profile.md is missing.
123
- ACTION: invoke \`init\` now (first contact: technical level + accompaniment dial) before the task.
124
+ ACTION: invoke \`init\` now (first contact, one question: technical or with analogies) before the task.
124
125
  If the user does not want a harness here: create .rsc/.no-harness
125
126
  ==========================
126
127
  `);
@@ -252,80 +253,6 @@ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
252
253
  } catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
253
254
  }
254
255
 
255
- // Update check: compare the installed version (.rsc/.version, written at install)
256
- // against the latest published on npm, and nudge the agent to offer an update.
257
- // Fail-silent (offline / missing baseline / parse error → nothing). Disable with
258
- // RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
259
- function isNewer(a, b) {
260
- const pa = String(a).split('.').map(Number);
261
- const pb = String(b).split('.').map(Number);
262
- for (let i = 0; i < 3; i++) {
263
- if ((pa[i] || 0) > (pb[i] || 0)) return true;
264
- if ((pa[i] || 0) < (pb[i] || 0)) return false;
265
- }
266
- return false;
267
- }
268
-
269
- // "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
270
- // notice on every session, and two people in two days reported bugs that were already fixed. A
271
- // version number names nothing anyone recognises. A symptom does: someone who sees a red error after
272
- // every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
273
- // is wrong as a symptom, not as a cause).
274
- //
275
- // The symptoms travel for free: the registry's `/latest` document is the published package.json, so
276
- // its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
277
- // It is text a model will read, so every entry is reduced to one short plain line or dropped.
278
- const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
279
- function fixesSince(list, installed, latest) {
280
- if (!Array.isArray(list)) return [];
281
- const out = [];
282
- for (const item of list) {
283
- if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
284
- if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
285
- if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
286
- const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
287
- if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
288
- }
289
- return out.slice(0, 8);
290
- }
291
-
292
- if (!process.env.RSC_NO_UPDATE_CHECK) {
293
- try {
294
- const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
295
- let doc = null;
296
- if (process.env.RSC_LATEST_JSON) doc = JSON.parse(process.env.RSC_LATEST_JSON);
297
- else if (process.env.RSC_LATEST) doc = { version: process.env.RSC_LATEST };
298
- else {
299
- const ctrl = new AbortController();
300
- const timer = setTimeout(() => ctrl.abort(), 1500);
301
- const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
302
- clearTimeout(timer);
303
- doc = await res.json();
304
- }
305
- const latest = doc?.version;
306
- if (installed && latest && isNewer(latest, installed)) {
307
- let fixes = [];
308
- try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
309
- if (fixes.length) {
310
- process.stdout.write(`
311
- ===== rsc update available =====
312
- rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
313
- ${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
314
- ACTION: before starting the task, tell the user in one line that the update fixes what they
315
- may be seeing, and if they say yes, run it in this project:
316
- npx @ericrisco/rsc@latest
317
- ================================
318
- `);
319
- } else {
320
- process.stdout.write(`
321
- ===== rsc update available =====
322
- rsc ${latest} is out — you have ${installed}.
323
- ACTION: tell the user a new version is available and, if they say yes, run:
324
- npx @ericrisco/rsc@latest
325
- (That reinstalls and refreshes the skill content to the latest.)
326
- ================================
327
- `);
328
- }
329
- }
330
- } catch { /* offline / no baseline / parse error → stay silent */ }
331
- }
256
+ // Update check and auto-update: the rule lives in auto-update.mjs, shared with the other assistants.
257
+ // Disable with RSC_NO_UPDATE_CHECK=1.
258
+ if (!process.env.RSC_NO_UPDATE_CHECK) process.stdout.write(await updateNotice(root));
@@ -0,0 +1,153 @@
1
+ // Wiring for the update check (`auto-update.mjs`). Every assistant gets the module in `.rsc/`; the
2
+ // ones with a session-start event also get a hook that runs it, in the same project-local config
3
+ // files the session memory uses, under a needle of its own so either can be wired or removed
4
+ // without touching the other.
5
+ //
6
+ // Kept apart from `memory.js` on purpose: the memory promises it makes no network request, and a
7
+ // test holds every file it wires to that. The update check exists to ask npm.
8
+ //
9
+ // Claude Code is not in CONFIG: its `session-start.mjs` already imports the module. An assistant
10
+ // with no hook at all is told by the always-on body to run the module on its first turn.
11
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url));
17
+ const NEEDLE = '.rsc/auto-update.mjs';
18
+ const PLUGIN_MARKER = 'rsc-update:managed';
19
+
20
+ const CONFIG = Object.freeze({
21
+ codex: '.codex/hooks.json',
22
+ cursor: '.cursor/hooks.json',
23
+ gemini: '.gemini/settings.json',
24
+ opencode: '.opencode/plugins/rsc-update.js',
25
+ });
26
+
27
+ const EVENT = Object.freeze({ codex: 'SessionStart', gemini: 'SessionStart', cursor: 'sessionStart' });
28
+
29
+ /** Assistants where the update runs on its own. Every other one depends on the agent running it. */
30
+ export const UPDATE_HOOK_TARGETS = Object.freeze(['claude', ...Object.keys(CONFIG)].sort());
31
+
32
+ const git = (cwd, args) => {
33
+ try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
34
+ };
35
+ const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
36
+ const tracked = (cwd, path) => Boolean(git(cwd, ['ls-files', '--', rel(cwd, path)]));
37
+
38
+ function exclude(cwd, path) {
39
+ if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
40
+ const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
41
+ if (!value) return;
42
+ const file = isAbsolute(value) ? value : resolve(cwd, value);
43
+ const pattern = `/${rel(cwd, path)}`;
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
46
+ if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
47
+ }
48
+
49
+ function readConfig(path) {
50
+ if (!existsSync(path)) return {};
51
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
52
+ }
53
+
54
+ const validHookConfig = (c) => Boolean(c) && typeof c === 'object' && !Array.isArray(c)
55
+ && (!Object.hasOwn(c, 'hooks') || (c.hooks && typeof c.hooks === 'object' && !Array.isArray(c.hooks) && Object.values(c.hooks).every(Array.isArray)));
56
+
57
+ function handler(target) {
58
+ const script = target === 'codex'
59
+ ? '"$(git rev-parse --show-toplevel)/.rsc/auto-update.mjs"'
60
+ : '".rsc/auto-update.mjs"';
61
+ const value = { type: 'command', command: `node ${script} hook ${target}`, timeout: target === 'gemini' ? 10000 : 10 };
62
+ if (target === 'codex') value.commandWindows = `node "$((git rev-parse --show-toplevel).Trim())/.rsc/auto-update.mjs" hook ${target}`;
63
+ if (target === 'gemini') value.name = 'rsc-update';
64
+ return value;
65
+ }
66
+
67
+ function strip(config) {
68
+ if (!validHookConfig(config) || !config.hooks) return config;
69
+ for (const event of Object.keys(config.hooks)) {
70
+ config.hooks[event] = config.hooks[event].filter((e) => !JSON.stringify(e).replaceAll('\\\\', '/').includes(NEEDLE));
71
+ if (!config.hooks[event].length) delete config.hooks[event];
72
+ }
73
+ if (!Object.keys(config.hooks).length) delete config.hooks;
74
+ return config;
75
+ }
76
+
77
+ const PLUGIN = `// ${PLUGIN_MARKER}
78
+ // rsc update check for OpenCode: checked once per OpenCode run, and the notice goes to the model to be
79
+ // relayed. It rides on EVERY system prompt of the run, not just the first: OpenCode's first call is
80
+ // its title generator, which would swallow a once-only notice before the agent ever sees it.
81
+ // Turn auto-update off with .rsc/.no-auto-update.
82
+ import { updateNotice } from '../../.rsc/auto-update.mjs';
83
+
84
+ export const RscUpdatePlugin = async ({ directory, worktree }) => {
85
+ const cwd = worktree || directory;
86
+ const pending = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => '');
87
+ return {
88
+ 'experimental.chat.system.transform': async (_input, output) => {
89
+ const said = (await pending).trim();
90
+ if (said && Array.isArray(output?.system)) output.system.push(said + '\\nMention this only in your first reply of the session.');
91
+ },
92
+ };
93
+ };
94
+ `;
95
+
96
+ export function updateManagedPaths(target, cwd = process.cwd()) {
97
+ const module = join(cwd, '.rsc', 'auto-update.mjs');
98
+ return CONFIG[target] ? [module, join(cwd, ...CONFIG[target].split('/'))] : [module];
99
+ }
100
+
101
+ /** Wire it. Always: being off is the runtime's `.no-auto-update`, and the check itself stays on. */
102
+ export function wireUpdate(target, cwd = process.cwd()) {
103
+ const module = join(cwd, '.rsc', 'auto-update.mjs');
104
+ mkdirSync(dirname(module), { recursive: true });
105
+ copyFileSync(join(HERE, 'auto-update.mjs'), module);
106
+ if (!CONFIG[target]) {
107
+ return { mode: target === 'claude' ? 'hook' : 'agent', paths: [module] };
108
+ }
109
+ const configPath = join(cwd, ...CONFIG[target].split('/'));
110
+ if (tracked(cwd, configPath)) return { mode: 'agent', reason: 'config-tracked', paths: [module] };
111
+ if (target === 'opencode' && existsSync(configPath) && !readFileSync(configPath, 'utf8').includes(PLUGIN_MARKER)) {
112
+ return { mode: 'agent', reason: 'plugin-collision', paths: [module] };
113
+ }
114
+ const config = target === 'opencode' ? null : readConfig(configPath);
115
+ if (target !== 'opencode' && !validHookConfig(config)) return { mode: 'agent', reason: 'config-invalid', paths: [module] };
116
+
117
+ mkdirSync(dirname(configPath), { recursive: true });
118
+ if (target === 'opencode') writeFileSync(configPath, PLUGIN);
119
+ else {
120
+ strip(config);
121
+ config.hooks ||= {};
122
+ const event = EVENT[target];
123
+ config.hooks[event] ||= [];
124
+ const h = handler(target);
125
+ if (target === 'cursor') { config.version ||= 1; delete h.type; delete h.timeout; config.hooks[event].push(h); }
126
+ else config.hooks[event].push({ hooks: [h] });
127
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
128
+ }
129
+ const paths = updateManagedPaths(target, cwd);
130
+ for (const path of paths) exclude(cwd, path);
131
+ return { mode: 'hook', paths };
132
+ }
133
+
134
+ export function unwireUpdate(target, cwd = process.cwd()) {
135
+ if (!CONFIG[target]) return [];
136
+ const path = join(cwd, ...CONFIG[target].split('/'));
137
+ const touched = [];
138
+ if (target === 'opencode') {
139
+ if (existsSync(path) && readFileSync(path, 'utf8').includes(PLUGIN_MARKER)) { rmSync(path, { force: true }); touched.push(path); }
140
+ } else if (existsSync(path) && readFileSync(path, 'utf8').replaceAll('\\\\', '/').includes(NEEDLE)) {
141
+ const config = readConfig(path);
142
+ if (config) { strip(config); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); touched.push(path); }
143
+ }
144
+ return touched;
145
+ }
146
+
147
+ export function updateArtifactsPresent(target, cwd = process.cwd()) {
148
+ if (!CONFIG[target]) return [];
149
+ const path = join(cwd, ...CONFIG[target].split('/'));
150
+ if (!existsSync(path)) return [];
151
+ const body = readFileSync(path, 'utf8').replaceAll('\\\\', '/');
152
+ return body.includes(NEEDLE) || body.includes(PLUGIN_MARKER) ? [path] : [];
153
+ }
@@ -1,76 +0,0 @@
1
- ---
2
- name: bro
3
- description: "Use whenever the user asks to make an answer or draft sound human, natural, plain-spoken or less AI-written — including a terse bro after the last response. Rewrites or drafts in the same language while preserving meaning, facts and channel. NOT a reusable brand voice (that is `brand-voice`), NOT translation (that is `translation-l10n`)."
4
- tags: [bro, human-writing, humano, plain-language, natural-language, rewrite, no-jargon]
5
- recommends: [unslop, brand-voice, technical-writing, translation-l10n]
6
- profiles: [minimal, core, full]
7
- origin: risco
8
- ---
9
-
10
- # bro — say it like a person
11
-
12
- Make the words sound like someone with something real to say. Keep the substance; remove the
13
- performance around it.
14
-
15
- ## The contract
16
-
17
- 1. **Find the source without ceremony.** A bare “bro”, “write like a human”, “hazlo más natural”,
18
- or equivalent points to the assistant's last answer. If the user pasted text, rewrite that. If
19
- they requested new copy, draft it directly. Ask for text only when no source exists.
20
- 2. **Preserve the payload.** Keep names, facts, numbers, links, commitments, constraints,
21
- uncertainty and necessary warnings. Do not improve the prose by inventing evidence, opinions,
22
- anecdotes or certainty.
23
- 3. **Match the room.** Keep the user's language and fit the audience, relationship and channel. A
24
- natural legal notice is still precise; a natural Slack message can be loose. “Human” does not
25
- automatically mean casual.
26
- 4. **Cut the bot-shaped scaffolding.** Remove throat-clearing, needless summaries, repeated
27
- conclusions, corporate filler, fake enthusiasm, abstract nouns and headings or bullets that do
28
- not help the reader navigate.
29
- 5. **Restore a real cadence.** Prefer concrete verbs, direct sentences and natural contractions.
30
- Vary sentence length when it helps. Use the words a person in this context would actually choose,
31
- without manufacturing slang, typos or choppy fragments.
32
- 6. **Return the writing, not a review of the writing.** By default, output the rewritten or newly
33
- drafted text with no “here is a more human version” preface. Explain the changes only if asked.
34
-
35
- When the original contains code, commands, URLs, quoted terms or regulated wording, leave those
36
- pieces exact unless the user explicitly asks to change them. Explain unavoidable jargon once in
37
- plain language instead of deleting precision.
38
-
39
- ## What “human” means here
40
-
41
- | Aim | The result |
42
- | --- | --- |
43
- | Plain, not simplistic | Easy to follow without losing the idea |
44
- | Concise, not incomplete | No padding; all load-bearing facts remain |
45
- | Warm, not performative | Appropriate care without canned empathy |
46
- | Conversational, not sloppy | Natural rhythm without fake mistakes |
47
- | Specific, not decorated | Concrete nouns and verbs instead of hype |
48
-
49
- ## Anti-patterns
50
-
51
- | Anti-pattern | Why it fails | Do this instead |
52
- | --- | --- | --- |
53
- | Add typos, slang or filler to seem human | It performs humanity and can sound less credible | Match the actual relationship and channel |
54
- | Replace technical terms with vague words | Readability bought with lost meaning is a defect | Keep or define the necessary term |
55
- | Invent a personal story or strong opinion | A smoother fabrication is still a fabrication | Preserve only what the source supports |
56
- | Use the same “casual” voice for every audience | A client email, README and condolence need different registers | Infer the room before choosing the cadence |
57
- | Promise the result is undetectable as AI | No rewrite can honestly guarantee that | Promise clarity and naturalness, not detector outcomes |
58
- | Announce and justify every edit | The preamble becomes the same friction the user asked to remove | Lead with the finished words |
59
-
60
- ## Where this ends
61
-
62
- `unslop` owns the other half of this job: the **named** audit of a text that already exists, tell by
63
- catalogued tell (puffery, `not just X but Y`, em dashes, bold-label lists, hedging), run before the
64
- text ships. This skill owns the **register**: which language, which relationship, which channel, and
65
- the cadence that fits them. A bare "bro" is always this skill. "Check this for AI tells before I
66
- publish it" is `unslop`. When a text needs both, audit first, register last.
67
-
68
- ## Output boundary
69
-
70
- Treat the rewritten text as an artifact: keep harness commentary outside it. If the task also
71
- requires a brújula block, place that block after a clear separation so it cannot be copied into an
72
- email, document or message by accident.
73
-
74
- ## Orientación (siempre)
75
-
76
- Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ✅ qué hiciste · 🧭 por qué · ➡️ siguiente, terminando en pregunta), calibrado al dial de `02-DOCS/wiki/harness/user-profile.md`. **Nunca termines en seco.** Protocolo completo: skill `orient` → `skills/orient/references/orientation-contract.md`. (Defiere a `suggest` el "¿instalo la skill que falta?".)
@@ -1,22 +0,0 @@
1
- # Evals — bro
2
-
3
- These cases test two different claims: that human-language requests route to `bro` without
4
- stealing adjacent writing jobs, and that the loaded skill produces a more natural result without
5
- changing the payload.
6
-
7
- ## Triggering
8
-
9
- Load only `bro` and run every `should_trigger` and `should_not_trigger` prompt in 3–5 fresh
10
- sessions. A positive passes when `bro` loads in a majority of trials. A negative passes when it
11
- stays quiet; also check that the declared `route_to` skill is the genuine owner. Require at least
12
- 90% accuracy across all cases.
13
-
14
- ## Capability
15
-
16
- Run the capability scenario in two fresh conditions: without `bro`, then with it. Use the same
17
- source answer and one consistent grader. Score each `must_include` point over three trials. The
18
- with-skill condition must satisfy at least 80% of the rubric and beat the baseline by at least 20
19
- percentage points.
20
-
21
- This is an LLM/human-judged A/B eval, not deterministic CI. Re-run it after changing either the
22
- description or the body; routing and prose quality are both wording-sensitive.