@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 +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +1 -0
- package/scripts/rsc.js +1 -1
- package/skills/ftd/SKILL.md +1 -1
- package/skills/worktrees/SKILL.md +19 -5
- package/targets/auto-update.mjs +8 -0
- package/targets/branch-guard.mjs +59 -4
- package/targets/claude.js +2 -0
- package/targets/memslop-offer.mjs +98 -0
- package/targets/opt-outs.js +1 -0
- package/targets/session-memory-adapter.mjs +2 -2
- package/targets/session-start.mjs +10 -0
- package/targets/team-safe-start.mjs +1 -0
- package/targets/update-wiring.js +6 -3
- package/targets/worktree-reaper.mjs +14 -1
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
|
|
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
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -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
|
|
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') {
|
package/skills/ftd/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
118
|
-
the
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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)
|
package/targets/auto-update.mjs
CHANGED
|
@@ -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/branch-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
+
}
|
package/targets/opt-outs.js
CHANGED
|
@@ -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
|
|
126
|
-
'`git worktree add .worktrees/<rama> -b <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);
|
package/targets/update-wiring.js
CHANGED
|
@@ -110,8 +110,8 @@ export default {
|
|
|
110
110
|
`;
|
|
111
111
|
|
|
112
112
|
export function updateManagedPaths(target, cwd = process.cwd()) {
|
|
113
|
-
const
|
|
114
|
-
return CONFIG[target] ? [
|
|
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';
|