@ericrisco/rsc 3.5.0 → 3.6.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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.5.0",
2
+ "version": "3.6.0",
3
3
  "counts": {
4
4
  "skills": 269,
5
5
  "agents": 34,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.5.0",
3
+ "version": "3.6.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": {
@@ -85,6 +85,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
85
85
  join(cwd, '.rsc', 'shell-unwrap.mjs'),
86
86
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
87
87
  join(cwd, '.rsc', 'auto-update.mjs'),
88
+ join(cwd, '.rsc', 'memslop-offer.mjs'),
88
89
  // team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
89
90
  join(cwd, '.rsc', 'team-safe-start.mjs'),
90
91
  join(cwd, '.rsc', 'trunk-policy.mjs'),
@@ -204,6 +204,14 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
204
204
  const root = (hook ? process.argv[4] : process.argv[2]) || process.cwd();
205
205
  let notice = '';
206
206
  try { if (!process.env.RSC_NO_UPDATE_CHECK) notice = (await updateNotice(root)).trim(); } catch { /* fail open */ }
207
+ // Codex has no session-start.mjs: its one SessionStart hook is this one, so the MemSlop offer rides
208
+ // here (macOS 14+ only, rsc projects only). A sibling loaded lazily; any failure → no offer.
209
+ if (target === 'codex' && existsSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'))) {
210
+ try {
211
+ const said = (await import('./memslop-offer.mjs')).memslopNotice().trim();
212
+ if (said) notice = notice ? `${notice}\n\n${said}` : said;
213
+ } catch { /* no offer */ }
214
+ }
207
215
  if (!hook && !notice) notice = 'rsc is up to date.';
208
216
  process.stdout.write(`${hookOutput(target, notice)}\n`);
209
217
  }
package/targets/claude.js CHANGED
@@ -123,6 +123,8 @@ export function wireHook(paths, sourceMd, policy = {}) {
123
123
  copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
124
124
  // session-start imports the shared update check, so it travels as a sibling too.
125
125
  copyFileSync(join(HERE, 'auto-update.mjs'), join(paths.projectRoot, '.rsc', 'auto-update.mjs'));
126
+ // session-start imports the optional MemSlop offer lazily, so it travels as a sibling too.
127
+ copyFileSync(join(HERE, 'memslop-offer.mjs'), join(paths.projectRoot, '.rsc', 'memslop-offer.mjs'));
126
128
  // team-safe-default: the 3.0 start-up duties and the trunk policy they read, as siblings.
127
129
  copyFileSync(join(HERE, 'team-safe-start.mjs'), join(paths.projectRoot, '.rsc', 'team-safe-start.mjs'));
128
130
  copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
@@ -0,0 +1,98 @@
1
+ // MemSlop offer (macOS only). MemSlop is a menu bar app that closes the dev servers, test browsers and
2
+ // other processes agents leave running. rsc tells the person about it once and, only on a yes, the
3
+ // agent installs it. Shared by session-start (Claude Code) and the Codex SessionStart hook in
4
+ // auto-update.mjs, so it travels as their sibling.
5
+ //
6
+ // Cost for anyone who does not want it: nothing. Off darwin it returns before touching the disk. On
7
+ // macOS it only reads files (no subprocess, no network), and its single side effect is the cadence
8
+ // stamp in ~/.rsc/memslop-offer.json. A "no" is per machine (~/.rsc/.no-memslop), because installing
9
+ // an app is a machine decision; a per-project marker would re-ask in every repo. Silence is not a yes:
10
+ // at most one offer every 7 days and 3 in total. Any error means no notice.
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
12
+ import { homedir, release as osRelease } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const MIN_DARWIN = 23; // macOS 14 Sonoma, the oldest MemSlop runs on
16
+ const EVERY_DAYS = 7;
17
+ const MAX_OFFERS = 3;
18
+
19
+ // RSC_MEMSLOP_HOME exists so tests can point it somewhere disposable (same escape hatch as RSC_SELLO_HOME).
20
+ // An injected home (either way) skips the system /Applications, so a real install cannot leak into a test.
21
+ // RSC_MEMSLOP_PLATFORM / RSC_MEMSLOP_RELEASE let a test run the real hook as another OS.
22
+ export function memslopHome() {
23
+ return process.env.RSC_MEMSLOP_HOME || homedir();
24
+ }
25
+
26
+ const statePath = (home) => join(home, '.rsc', 'memslop-offer.json');
27
+
28
+ // Does this agent config connect MemSlop's session-end hook? Missing file → no. Malformed → throws,
29
+ // which the caller turns into silence.
30
+ function hookConnected(file) {
31
+ if (!existsSync(file)) return false;
32
+ const config = JSON.parse(readFileSync(file, 'utf8'));
33
+ const entries = config?.hooks?.SessionEnd;
34
+ if (!Array.isArray(entries)) return false;
35
+ return entries.some((e) => Array.isArray(e?.hooks) && e.hooks.some((h) =>
36
+ typeof h?.command === 'string' && h.command.includes('memslop') && h.command.includes('hook session-end')));
37
+ }
38
+
39
+ // 'offer' (not installed), 'connect' (CLI present, a hook missing) or null (opted out, too old, all set).
40
+ export function memslopState(home = memslopHome(), system = !process.env.RSC_MEMSLOP_HOME) {
41
+ if (existsSync(join(home, '.rsc', '.no-memslop'))) return null;
42
+ const dirs = (system ? ['/Applications'] : []).concat(join(home, 'Applications'));
43
+ const app = dirs.map((d) => join(d, 'MemSlop.app')).find((p) => existsSync(p));
44
+ if (!app) return 'offer';
45
+ // MemSlop < 1.2 has no command line tool and does not update itself: stay silent.
46
+ if (!existsSync(join(app, 'Contents', 'Helpers', 'memslop'))) return null;
47
+ const agents = [['.claude', 'settings.json'], ['.codex', 'hooks.json']];
48
+ const missing = agents.some(([dir, file]) => existsSync(join(home, dir)) && !hookConnected(join(home, dir, file)));
49
+ return missing ? 'connect' : null;
50
+ }
51
+
52
+ const OFFER = `
53
+ ===== rsc mem-slop (optional, macOS) =====
54
+ MemSlop is a menu bar app that closes the dev servers, test browsers and other processes
55
+ AI agents leave running. Free, open source; the first day it closes nothing, it only shows
56
+ what it would close and asks.
57
+ ACTION: offer it ONCE, in one line, to the user. On yes, run:
58
+ curl -fsSL https://raw.githubusercontent.com/ericrisco/mem-slop/main/install.sh | bash
59
+ then \`memslop hooks install\` (also optional; ask). On no, create ~/.rsc/.no-memslop.
60
+ Never install it without that yes.
61
+ =========================================
62
+ `;
63
+
64
+ const CONNECT = `
65
+ ===== rsc mem-slop hooks (optional, macOS) =====
66
+ MemSlop is installed but not connected to every agent here. \`memslop hooks install\` lets it clean
67
+ up the moment a Claude Code or Codex session ends; \`memslop hooks uninstall\` undoes it.
68
+ ACTION: offer it ONCE, in one line, to the user, and run it only on a yes. On no, create ~/.rsc/.no-memslop.
69
+ ================================================
70
+ `;
71
+
72
+ /**
73
+ * The block to print this session, or ''. Records the offer when it returns one.
74
+ * `platform`, `release` and `now` are injectable for tests.
75
+ */
76
+ export function memslopNotice({
77
+ platform = process.env.RSC_MEMSLOP_PLATFORM || process.platform,
78
+ release = process.env.RSC_MEMSLOP_RELEASE,
79
+ home,
80
+ now = Date.now(),
81
+ } = {}) {
82
+ if (platform !== 'darwin') return '';
83
+ try {
84
+ if (Number.parseInt(release ?? osRelease(), 10) < MIN_DARWIN) return '';
85
+ const dir = home ?? memslopHome();
86
+ let seen = { offers: 0, last: null };
87
+ if (existsSync(statePath(dir))) seen = JSON.parse(readFileSync(statePath(dir), 'utf8'));
88
+ const offers = Number(seen.offers) || 0;
89
+ if (offers >= MAX_OFFERS) return '';
90
+ const last = seen.last ? new Date(seen.last).getTime() : NaN;
91
+ if (!Number.isNaN(last) && (now - last) / 86400000 < EVERY_DAYS) return '';
92
+ const state = memslopState(dir, home === undefined && !process.env.RSC_MEMSLOP_HOME);
93
+ if (!state) return '';
94
+ mkdirSync(join(dir, '.rsc'), { recursive: true });
95
+ writeFileSync(statePath(dir), JSON.stringify({ offers: offers + 1, last: new Date(now).toISOString() }) + '\n');
96
+ return state === 'offer' ? OFFER : CONNECT;
97
+ } catch { return ''; }
98
+ }
@@ -39,6 +39,7 @@ export const MACHINE_OPT_OUTS = [
39
39
  'context7', // an MCP connection is machine wiring, and its consent is individual
40
40
  'git', // only fires with no .git/, which a clone cannot be
41
41
  'harness', // "not offered again ON THIS MACHINE" — the bootstrap's own words
42
+ 'memslop', // an app install is machine wiring, and its consent is individual (~/.rsc/.no-memslop)
42
43
  'scope-check',// compares the project and home scopes OF THIS MACHINE
43
44
  ];
44
45
 
@@ -212,6 +212,16 @@ then reload the session. If you don't want it here: create .rsc/.no-context7.
212
212
  `);
213
213
  }
214
214
 
215
+ // MemSlop (optional, macOS 14+ only): one offer per machine, a permanent no in ~/.rsc/.no-memslop.
216
+ // Under emitBody so a project wired in both scopes prints it once. The module is a sibling loaded
217
+ // lazily: missing or failing, it stays silent and never takes the other notices down with it.
218
+ if (emitBody && profileExists) {
219
+ try {
220
+ const M = await import('./memslop-offer.mjs');
221
+ process.stdout.write(M.memslopNotice());
222
+ } catch { /* no notice */ }
223
+ }
224
+
215
225
  // Periodic skill audit: for active rsc projects, nudge to re-audit when the last run
216
226
  // is stale (or never ran). Running \`rsc audit\` stamps .rsc/audit.json and silences
217
227
  // this for the cadence. Opt out with .rsc/.no-audit.
@@ -110,8 +110,8 @@ export default {
110
110
  `;
111
111
 
112
112
  export function updateManagedPaths(target, cwd = process.cwd()) {
113
- const module = join(cwd, '.rsc', 'auto-update.mjs');
114
- return CONFIG[target] ? [module, join(cwd, ...CONFIG[target].split('/'))] : [module];
113
+ const modules = [join(cwd, '.rsc', 'auto-update.mjs'), join(cwd, '.rsc', 'memslop-offer.mjs')];
114
+ return CONFIG[target] ? [...modules, join(cwd, ...CONFIG[target].split('/'))] : modules;
115
115
  }
116
116
 
117
117
  /** Wire it. Always: being off is the runtime's `.no-auto-update`, and the check itself stays on. */
@@ -119,8 +119,11 @@ export function wireUpdate(target, cwd = process.cwd()) {
119
119
  const module = join(cwd, '.rsc', 'auto-update.mjs');
120
120
  mkdirSync(dirname(module), { recursive: true });
121
121
  copyFileSync(join(HERE, 'auto-update.mjs'), module);
122
+ // The Codex hook imports the MemSlop offer lazily, so it travels as a sibling for every target.
123
+ const offer = join(cwd, '.rsc', 'memslop-offer.mjs');
124
+ copyFileSync(join(HERE, 'memslop-offer.mjs'), offer);
122
125
  if (!CONFIG[target]) {
123
- return { mode: target === 'claude' ? 'hook' : 'agent', paths: [module] };
126
+ return { mode: target === 'claude' ? 'hook' : 'agent', paths: [module, offer] };
124
127
  }
125
128
  const configPath = join(cwd, ...CONFIG[target].split('/'));
126
129
  if (tracked(cwd, configPath)) return { mode: 'agent', reason: 'config-tracked', paths: [module] };