@ericrisco/rsc 2.1.0 → 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.
package/README.md CHANGED
@@ -355,8 +355,9 @@ pinned version. It asks; it does not install anything on its own and it does not
355
355
  whatever you sat down to do. Say no and it stops asking on that machine.
356
356
 
357
357
  Not `@latest`, and the difference is the whole point of sharing: a teammate who clones in three
358
- months gets what you had, not what shipped since. Upgrading is something a person decides, writes
359
- into `.rsc.json`, and commits — and then it reaches everyone through git, like any other change.
358
+ months gets what you had, not what shipped since. Once it is built, the same rules as any project
359
+ apply: releases in the same major install themselves (see [Update](#update)), and the new
360
+ `catalogVersion` in `.rsc.json` reaches everyone when somebody commits it.
360
361
 
361
362
  When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
362
363
  matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
@@ -403,8 +404,26 @@ so rsc does not repair, move or delete them — not even when rebuilding from sc
403
404
 
404
405
  ## Update
405
406
 
406
- `rsc` is an npm package, so updating is two steps — bump the package, then
407
- re-sync what's already wired into your project:
407
+ **rsc updates itself.** When a session starts, rsc checks npm for a newer version:
408
+
409
+ - **Same major** (`2.1.0 → 2.1.1` or `2.2.0`): it installs that exact version in the background.
410
+ It takes effect in the next session, and that session tells you it happened.
411
+ - **New major** (`2.x → 3.0.0`): it can change how the harness works, so the assistant asks first.
412
+ - **Failed:** the assistant asks instead, and rsc retries once a day. The output is in
413
+ `.rsc/auto-update.log`.
414
+
415
+ A new install asks whether you want this (yes by default). To turn it off later, create
416
+ `.rsc/.no-auto-update`; every release then asks. It is a project decision: it travels in
417
+ `.rsc.json` like the other switches. The update can leave harness files changed in git; rsc never
418
+ commits them for you.
419
+
420
+ Where it runs on its own: **Claude Code, Codex, Gemini CLI, Cursor and OpenCode**, from a
421
+ session-start hook. Every other assistant has no such hook, so the always-on instructions ask the
422
+ agent to run `node .rsc/auto-update.mjs` on its first turn — same rules, but it depends on the agent
423
+ doing it (and some assistants ask your permission before running a command). Codex asks you to
424
+ trust new hooks once.
425
+
426
+ To update by hand, bump the package, then re-sync what's already wired into your project:
408
427
 
409
428
  ```bash
410
429
  npm install -g @ericrisco/rsc@latest # global install: pull the newest catalog
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.1.0",
2
+ "version": "2.2.0",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  wireMemory, unwireMemory, memoryManagedPaths, memoryModeFor, memoryEnabledForProject, memoryArtifactsPresent,
24
24
  } from '../targets/memory.js';
25
+ import { wireUpdate, unwireUpdate, updateManagedPaths, updateArtifactsPresent } from '../targets/update-wiring.js';
25
26
 
26
27
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
27
28
  const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
@@ -78,6 +79,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
78
79
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
79
80
  join(cwd, '.rsc', 'hook-once.mjs'),
80
81
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
82
+ join(cwd, '.rsc', 'auto-update.mjs'),
81
83
  ];
82
84
  // The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
83
85
  // whether or not the code guards are.
@@ -95,6 +97,7 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
95
97
  const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
96
98
  if (policy?.context7 === false) out.push(join(cwd, '.rsc', '.no-context7'));
97
99
  if (policy?.memory !== false) out.push(...memoryManagedPaths(target, cwd));
100
+ out.push(...updateManagedPaths(target, cwd));
98
101
  if (targetHasAgents(target)) {
99
102
  const state = readState(paths.stateFile);
100
103
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
@@ -297,6 +300,8 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
297
300
  ? { mode: 'disabled', reason: 'onboarding-policy', paths: unwireMemory(target, cwd) }
298
301
  : wireMemory(target, cwd);
299
302
  state.memory = { mode: memoryResult.mode, reason: memoryResult.reason, paths: memoryResult.paths };
303
+ const updateResult = wireUpdate(target, cwd);
304
+ state.update = { mode: updateResult.mode, ...(updateResult.reason ? { reason: updateResult.reason } : {}) };
300
305
  const context7OptOut = join(cwd, '.rsc', '.no-context7');
301
306
  if (policy?.context7 === false) {
302
307
  mkdirSync(dirname(context7OptOut), { recursive: true });
@@ -596,6 +601,7 @@ export function removeTargetInstall({ target, home, cwd = process.cwd() }) {
596
601
  }
597
602
  unwireHook(target, paths);
598
603
  unwireMemory(target, cwd);
604
+ unwireUpdate(target, cwd);
599
605
  rmSync(paths.stateFile, { force: true });
600
606
  const gitignore = join(cwd, '.gitignore');
601
607
  if (existsSync(gitignore)) {
@@ -704,8 +710,10 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
704
710
  if (!dryRun) {
705
711
  removed.push(...unwireHook(target, paths));
706
712
  removed.push(...unwireMemory(target, cwd));
713
+ removed.push(...unwireUpdate(target, cwd));
707
714
  } else {
708
715
  removed.push(...memoryArtifactsPresent(target, cwd));
716
+ removed.push(...updateArtifactsPresent(target, cwd));
709
717
  }
710
718
  // A lost state file also loses proof of ownership. Leave same-named user files
711
719
  // behind rather than guessing from a catalog id and deleting their work.
@@ -137,7 +137,7 @@ cargan al arrancar la sesión."* Full skill map and sample printouts → `refere
137
137
 
138
138
  ### Phase 4 — GROUND
139
139
 
140
- Four checks once the skills are in. The SessionStart hook nudges each of these too; doing them here
140
+ Five checks once the skills are in. The SessionStart hook nudges each of these too; doing them here
141
141
  means the user starts clean.
142
142
 
143
143
  1. **Version control.** No `.git/` → offer `git init`; the SDD chain and the ship guard assume it.
@@ -154,6 +154,15 @@ means the user starts clean.
154
154
  `WHERE`, `dd` to a device, `curl | bash` — and asks for a safer alternative. A fully `technical`
155
155
  user is never guarded. It turns off only if the user explicitly asks: `.rsc/.no-danger-guard`.
156
156
  Mention it when you record `non-technical`, so a later block is not a surprise.
157
+ 5. **Automatic updates.** Ask once, yes by default:
158
+ > *"Cuando salga una versión nueva de rsc, ¿la instalo sola? Las que cambian mucho (major) te las
159
+ > preguntaré siempre. (sí/no)"*
160
+
161
+ > *"When a new rsc version comes out, should I install it on my own? Big ones (major) I will
162
+ > always ask about. (yes/no)"*
163
+
164
+ Yes, or no clear answer → do nothing; it is on. No → create `.rsc/.no-auto-update`: every
165
+ release then asks. It is a project switch, recorded in `.rsc.json` on the next sync.
157
166
 
158
167
  ### Phase 5 — HANDOFF
159
168
 
@@ -13,6 +13,12 @@ const MARK_END = '<!-- rsc-suggest:end -->';
13
13
  // The exact separator wireHook writes before an appended block, so unwireHook can take
14
14
  // back precisely what it gave and leave the rest of the file untouched.
15
15
  const SEAM = '\n\n';
16
+ // Most assistants that read a rules file have no session-start hook, so nothing checks for a new rsc
17
+ // on its own. The agent does it instead. Here and not in suggest's body: the body is paid on every
18
+ // turn by every assistant, Claude Code included, which already checks from its hook.
19
+ export const UPDATE_NOTE = '**rsc updates.** Claude Code, Codex, Gemini CLI, Cursor and OpenCode check for a new rsc on their '
20
+ + 'own. In any other assistant, run `node .rsc/auto-update.mjs` once on the first turn of a session and relay any '
21
+ + 'notice it prints in one line; "up to date" needs no mention.\n';
16
22
 
17
23
  export function writeSkill(id, fromDir, toPath) {
18
24
  return linkOrCopy(fromDir, toPath);
@@ -23,7 +29,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
23
29
  const body = policy.codeHooks === false
24
30
  ? `# rsc-suggest — always-on operations layer\n\nRead \`02-DOCS/wiki/harness/user-profile.md\` before acting. Use \`orient\` to keep the user situated and \`suggest\` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n`
25
31
  : full;
26
- const block = `${MARK_START}\n${body}\n${MARK_END}`;
32
+ const block = `${MARK_START}\n${body}\n${UPDATE_NOTE}\n${MARK_END}`;
27
33
  let doc = existsSync(paths.hookTarget) ? readFileSync(paths.hookTarget, 'utf8') : '';
28
34
  if (doc.includes(MARK_START)) {
29
35
  doc = doc.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`), block);
@@ -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
  /**
@@ -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';
@@ -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
+ }