@ericrisco/rsc 3.4.0 → 3.5.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/rsc.js +1 -1
- package/skills/ftd/SKILL.md +1 -1
- package/skills/worktrees/SKILL.md +19 -5
- package/targets/branch-guard.mjs +59 -4
- package/targets/session-memory-adapter.mjs +2 -2
- package/targets/team-safe-start.mjs +1 -0
- 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/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/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
|
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -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';
|