@ericrisco/rsc 3.4.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/README.md CHANGED
@@ -239,7 +239,7 @@ project. rsc handles four things by default:
239
239
  | | What happens | Turn it off |
240
240
  | --- | --- | --- |
241
241
  | **You choose how the project works** | The install asks once: straight on `main`, or branches and pull requests? With branches, a commit on `main` is refused and the agent asks before each change: this branch, a new one, or unlock `main`? It never opens a branch on its own. | «unlock main», or `rsc main unlock` |
242
- | **One workspace per agent** | If another session works in the same folder, new work goes to `.worktrees/<branch>/`, inside the project and never committed. A `post-merge` hook retires it once the branch lands. | `rsc isolation off` |
242
+ | **One workspace per agent** | If another session works in the same folder, new work goes to `.worktrees/<branch>/` at the root you work in (never inside a project folder) and is never committed. A `post-merge` hook retires it once the branch lands. | `rsc isolation off` |
243
243
  | **The agent picks the method** | FTD for simple changes, SDD for complex ones. The agent decides and says why. | Ask for the other lane |
244
244
  | **Knowledge reaches everyone** | `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` travel through one exchange branch, `rsc/knowledge`, and reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
245
245
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.4.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.4.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'),
package/scripts/rsc.js CHANGED
@@ -927,7 +927,7 @@ async function main() {
927
927
  : 'rsc main lock: branches and pull requests for this project — the default branch is closed for the agent, and it asks before each code change where it goes. Commit .rsc.json so the team gets the same decision.')
928
928
  : (sub === OFF
929
929
  ? 'rsc isolation off: sessions may share this folder without a worktree. Commit .rsc.json so the team gets the same decision.'
930
- : 'rsc isolation on: with another session active in this folder, new work goes to .worktrees/<branch>/. Commit .rsc.json so the team gets the same decision.'));
930
+ : 'rsc isolation on: with another session active in this folder, new work goes to .worktrees/<branch>/ at the root, never inside a project folder. Commit .rsc.json so the team gets the same decision.'));
931
931
  return;
932
932
  }
933
933
  if (sub === 'status') {
@@ -73,7 +73,7 @@ says which on every turn) — never a branch opened on your own:
73
73
  is no exception: an unrelated change does not go into it without asking. A new branch is named by
74
74
  intent and closes with a pull request. Unlocking is `rsc main unlock`; never without that answer.
75
75
  - **Docs, wiki or config only** → no question, no branch.
76
- - **Another session working in this same folder** → a worktree in `.worktrees/<branch>/`, never a
76
+ - **Another session working in this same folder** → a worktree in `.worktrees/<branch>/` at the root you work in (never inside a project folder), never a
77
77
  branch switch under the other session. Not a question: rsc tells you when that is the case.
78
78
 
79
79
  A worktree only when another session is there: alone, setting one up costs more than it protects.
@@ -114,11 +114,25 @@ git worktree add -b feat/<slug> .worktrees/<slug> origin/<default-branch>
114
114
  # work happens in .worktrees/<slug>; the main checkout is untouched
115
115
  ```
116
116
 
117
- **Inside the repo, always — never a sibling.** `.worktrees/` is already gitignored by the harness,
118
- the reaper already recognises it as its own, and the difference matters after the work lands: a
119
- sibling directory sits outside the tree, so it survives every cleanup of the repository and stops
120
- being visible to whoever would retire it. Two of them accumulated here before this was written down.
121
- Deleting the repo should delete its worktrees with it.
117
+ **At the root you work in, always — never inside a project folder, never a sibling.** The root is
118
+ the folder holding `.rsc.json`. For the repo at that root the worktree is `.worktrees/<slug>`. For a
119
+ project that lives in a folder under it (a workspace with `03-PROJECTS/<p>/`), it is
120
+ `<root>/.worktrees/<p>-<slug>`, and the path is written absolute — a relative one resolves inside
121
+ the project, which is how they ended up there:
122
+
123
+ ```bash
124
+ git -C 03-PROJECTS/<p> fetch origin
125
+ git -C 03-PROJECTS/<p> worktree add -b feat/<slug> <root>/.worktrees/<p>-<slug> origin/<default-branch>
126
+ ```
127
+
128
+ A worktree at the root is a fresh checkout with nothing above it to borrow from: install its
129
+ dependencies inside it (`npm ci`, `uv sync`, …) before running anything. Inside the project, a
130
+ missing `node_modules` was silently taken from the parent; at the root it fails loudly instead.
131
+
132
+ rsc's branch guard denies any other place, with the right command in its message. `.worktrees/` is
133
+ already gitignored at the root, the reaper recognises both shapes as its own, and a sibling
134
+ directory would sit outside the tree, survive every cleanup and stop being visible to whoever would
135
+ retire it. Deleting the workspace deletes its worktrees with it.
122
136
 
123
137
  ```bash
124
138
  # branch-only path (the degenerate case: no separate directory needed)
@@ -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
  }
@@ -10,13 +10,16 @@
10
10
  // B · another session is working in this same checkout (session-memory-core.mjs). Switching
11
11
  // branches here would change its files under it, so it is denied, and the message says how to
12
12
  // carry on: a worktree under `.worktrees/`.
13
+ // C · a worktree goes in `.worktrees/` at the root the session works in, never inside a project
14
+ // folder (Eric, 2026-10-27). In a workspace holding its projects as folders, `git -C <p>
15
+ // worktree add .worktrees/<b>` resolved inside the project; it is denied with the root's path.
13
16
  //
14
17
  // Every denial carries its way out (constitution P6). Only what the AGENT runs is seen: a person
15
18
  // committing in their own terminal is never touched (spec, P3). FAIL-OPEN: no repo, no git, a module
16
19
  // missing, anything unclear → allow. Opt-outs, both PROJECT switches: `.rsc/.no-trunk-guard`
17
20
  // («desbloquea main» / `rsc main unlock`) and `.rsc/.no-worktree-isolation` (`rsc isolation off`).
18
21
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
19
- import { isAbsolute, join, resolve } from 'node:path';
22
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
20
23
  import { spawnSync } from 'node:child_process';
21
24
  import { fileURLToPath } from 'node:url';
22
25
 
@@ -56,6 +59,52 @@ export function pushesTo(seg, trunk) {
56
59
  });
57
60
  }
58
61
 
62
+ export const ADDS_WORKTREE = new RegExp(String.raw`\bgit\s+${GIT_OPTS}worktree\s+add(?![\w-])`);
63
+
64
+ /** `git worktree add [opts] <path> [<commit-ish>]` → { path, branch } (branch from -b/-B), or null. */
65
+ export function worktreeAddTarget(seg) {
66
+ const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
67
+ if (t.shift() !== 'worktree' || t.shift() !== 'add') return null;
68
+ let branch = null;
69
+ let path = null;
70
+ for (let i = 0; i < t.length; i++) {
71
+ if (/^-[bB]$/.test(t[i])) { branch = t[++i] ?? null; continue; }
72
+ if (t[i] === '--reason') { i++; continue; }
73
+ if (!t[i].startsWith('-') && path === null) path = t[i];
74
+ }
75
+ return path === null ? null : { path, branch };
76
+ }
77
+
78
+ /** realpath of what exists, with the not-yet-created tail kept: a worktree path does not exist yet. */
79
+ function realish(p) {
80
+ let head = p;
81
+ const tail = [];
82
+ while (!existsSync(head)) { const up = dirname(head); if (up === head) return p; tail.unshift(basename(head)); head = up; }
83
+ try { return join(realpathSync(head), ...tail); } catch { return p; }
84
+ }
85
+
86
+ const within = (p, dir) => p === dir || p.startsWith(dir + sep);
87
+
88
+ /** Rule C: the reason to deny this `worktree add`, or null. Repos outside the root are not judged. */
89
+ export function worktreePlaceDenial({ root, here, seg, at }) {
90
+ const target = worktreeAddTarget(seg);
91
+ if (!target) return null;
92
+ const home = realish(root);
93
+ if (!within(here, home)) return null;
94
+ const dest = realish(isAbsolute(target.path) ? target.path : resolve(at, target.path));
95
+ const place = join(home, '.worktrees');
96
+ if (within(dest, place) && dest !== place) return null;
97
+ const slug = (target.branch || basename(dest)).replace(/[\\/]+/g, '-');
98
+ const b = target.branch ? ` -b ${target.branch}` : '';
99
+ const rel = relative(home, here).split(sep).join('/');
100
+ const how = here === home
101
+ ? `\`git worktree add .worktrees/${slug}${b}\``
102
+ : `\`git -C ${rel} worktree add ${join(place, `${basename(here)}-${slug}`)}${b}\``;
103
+ return 'rsc: a worktree goes in `.worktrees/` at the root you work in, never inside a project folder ' +
104
+ `and never beside it (${dest} is not under ${place}). Create it with ${how} and work inside it; ` +
105
+ 'tell the person that is where the work now runs. To stop rsc judging worktrees: `npx @ericrisco/rsc isolation off`.';
106
+ }
107
+
59
108
  export const unquoted = (command) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
60
109
 
61
110
  /** Where the git command actually runs: `git -C <dir>`, or a leading `cd <dir> &&`, else the cwd. */
@@ -133,7 +182,7 @@ export async function evaluate({ root, command, cwd, sessionId }) {
133
182
  // the segments judged are the ones the shell would really run (shell-unwrap.mjs).
134
183
  const raw = SH.expand(command);
135
184
  const segs = raw.map(unquoted);
136
- if (!segs.some((s) => COMMITS_HERE.test(s) || MOVES_BRANCH.test(s) || MOVES_REF.test(s) || PUSHES.test(s))) return null;
185
+ if (!segs.some((s) => COMMITS_HERE.test(s) || MOVES_BRANCH.test(s) || MOVES_REF.test(s) || PUSHES.test(s) || ADDS_WORKTREE.test(s))) return null;
137
186
  const self = (rel) => new URL(rel, import.meta.url);
138
187
  let dir = cwd || root;
139
188
  const branchAt = new Map(); // toplevel → branch the chain has moved it to
@@ -146,13 +195,19 @@ export async function evaluate({ root, command, cwd, sessionId }) {
146
195
  const moves = MOVES_BRANCH.test(seg);
147
196
  const movesRef = MOVES_REF.test(seg);
148
197
  const pushes = PUSHES.test(seg);
149
- if (!commits && !moves && !movesRef && !pushes) continue;
198
+ const addsWorktree = ADDS_WORKTREE.test(seg);
199
+ if (!commits && !moves && !movesRef && !pushes && !addsWorktree) continue;
150
200
  const at = effectiveDir(raw[i] || seg, dir);
151
201
  const git = gitAt(at);
152
202
  const top = git('rev-parse', '--show-toplevel');
153
203
  if (!top) continue;
154
204
  const here = (() => { try { return realpathSync(top); } catch { return top; } })();
155
205
 
206
+ if (addsWorktree && !existsSync(join(root, '.rsc', '.no-worktree-isolation'))) {
207
+ const reason = worktreePlaceDenial({ root, here, seg, at });
208
+ if (reason) return reason;
209
+ }
210
+
156
211
  if (moves) {
157
212
  const target = moveTarget(seg, at);
158
213
  if (target) {
@@ -237,7 +292,7 @@ async function isolationDenial({ root, here, sessionId, self }) {
237
292
  if (!others.length) return null;
238
293
  const who = others.map((o) => `${o.target}${o.branch ? ` on "${o.branch}"` : ''}, ${ago(o.updatedAt)}`).join('; ');
239
294
  return `Another session is working in this same folder (${who}). Switching branches here would change its files under it. ` +
240
- 'Work in a worktree instead, inside the project: `git worktree add .worktrees/<branch> -b <branch>` (or without -b for an existing branch) and run everything inside `.worktrees/<branch>/`; tell the person that is where the project now runs. ' +
295
+ 'Work in a worktree instead, in `.worktrees/` at the root (never inside a project folder): `git worktree add .worktrees/<branch> -b <branch>` (or without -b for an existing branch; for a project in a folder, `git -C <project> worktree add <root>/.worktrees/<project>-<branch> -b <branch>`) and run everything inside it; tell the person that is where the project now runs. ' +
241
296
  'To turn this rule off for the project: `npx @ericrisco/rsc isolation off`.';
242
297
  }
243
298
 
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
 
@@ -122,8 +122,8 @@ export function isolationContext({ project, here, sessionId, target }) {
122
122
  if (!others.length) return '';
123
123
  const who = others.map((o) => `${o.target}${o.branch ? ` en «${o.branch}»` : ''}`).join(', ');
124
124
  return `rsc · aislamiento: otra sesión está trabajando en esta misma carpeta (${who}). ` +
125
- 'Cualquier cambio de esta petición se hace en un worktree dentro del proyecto: ' +
126
- '`git worktree add .worktrees/<rama> -b <rama>` y se trabaja dentro de `.worktrees/<rama>/`. ' +
125
+ 'Cualquier cambio de esta petición se hace en un worktree en `.worktrees/` de la raíz, nunca dentro de una carpeta de proyecto: ' +
126
+ '`git worktree add .worktrees/<rama> -b <rama>` (si el proyecto está en una subcarpeta, `git -C <proyecto> worktree add <raíz>/.worktrees/<proyecto>-<rama> -b <rama>`, con la ruta absoluta) y se trabaja dentro. ' +
127
127
  'No cambies de rama en esta carpeta. Dile a la persona en una línea dónde estás trabajando, para que ' +
128
128
  'abra o levante el proyecto desde allí.';
129
129
  }
@@ -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.
@@ -86,6 +86,7 @@ export async function relocateOldWorktrees(root) {
86
86
  for (const wt of W.listWorktrees(root)) {
87
87
  const path = real(wt.path);
88
88
  if (isInside(path, home)) continue; // already inside
89
+ if (W.atWorkspaceRoot?.(path, home)) continue; // a project folder's worktree, at the workspace root on purpose
89
90
  if (W.provenanceOf(root, { ...wt, path }) !== 'rsc') continue;
90
91
  if (others(path).length) { left.push(`${path} (una sesión trabaja en él)`); continue; }
91
92
  const name = basename(path);
@@ -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] };
@@ -169,13 +169,26 @@ export function listWorktrees(root) {
169
169
  * accumulates, nothing is lost), while one the user made by hand that happens to match the
170
170
  * convention would be indistinguishable from ours. So one signal alone is `ambiguous`, not `rsc`.
171
171
  */
172
+ /**
173
+ * Where a project folder's worktree goes since 2026-10-27: `<root>/.worktrees/<repo>-<branch>`, at the
174
+ * workspace root that contains the repo (branch-guard rule C). Named after the repo, in a
175
+ * `.worktrees/` of an ancestor of the repo — both, or it is not this convention.
176
+ */
177
+ export function atWorkspaceRoot(p, home) {
178
+ const parent = dirname(p);
179
+ if (basename(parent) !== '.worktrees' || !basename(p).startsWith(`${basename(home)}-`)) return false;
180
+ const ws = dirname(parent);
181
+ return home.startsWith(ws + sep) && home !== ws;
182
+ }
183
+
172
184
  export function provenanceOf(root, wt) {
173
185
  const p = wt.path;
174
186
  const home = real(root);
175
187
  const inside = (dir) => p === dir || p.startsWith(dir + sep);
176
188
  const location = inside(join(home, '.worktrees'))
177
189
  || inside(join(home, 'worktrees'))
178
- || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
190
+ || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`))
191
+ || atWorkspaceRoot(p, home);
179
192
  const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
180
193
  if (location && branch) return 'rsc';
181
194
  if (location || branch) return 'ambiguous';