@ericrisco/rsc 3.7.1 → 3.7.3
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
package/package.json
CHANGED
|
@@ -18,11 +18,13 @@ export const ONBOARDING_VALUES = Object.freeze({
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
const ignored = new Set([
|
|
21
|
-
|
|
21
|
+
// `vendor` is Composer's (and Go's/Bundler's) `node_modules`: third-party code, not the project. A
|
|
22
|
+
// Laravel app was read as Python because one vendored package shipped a `docs/requirements.txt` (#325).
|
|
23
|
+
'.git', '.rsc', 'node_modules', 'vendor', '.venv', '.next', 'dist', 'build', 'coverage', '__pycache__', '.dart_tool',
|
|
22
24
|
'.claude', '.codex', '.cursor', '.opencode', '.amp', '.jules', '.zed', '.gemini', '.antigravity',
|
|
23
25
|
'.windsurf', '.clinerules', '.roo', '.continue', '.junie', '.kiro', '.aider',
|
|
24
26
|
]);
|
|
25
|
-
const manifests = new Set(['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pubspec.yaml', 'requirements.txt']);
|
|
27
|
+
const manifests = new Set(['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pubspec.yaml', 'requirements.txt', 'composer.json']);
|
|
26
28
|
const sourceExt = new Set(['.js', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs', '.dart', '.java', '.kt', '.swift', '.php', '.rb']);
|
|
27
29
|
|
|
28
30
|
const clean = (value) => String(value ?? '').trim();
|
|
@@ -152,6 +154,14 @@ export function scanProject(root = process.cwd()) {
|
|
|
152
154
|
} catch { stacks.add('node'); }
|
|
153
155
|
}
|
|
154
156
|
if (entry.name === 'pyproject.toml' || entry.name === 'requirements.txt') stacks.add('python');
|
|
157
|
+
if (entry.name === 'composer.json') {
|
|
158
|
+
stacks.add('php');
|
|
159
|
+
try {
|
|
160
|
+
const composer = JSON.parse(readFileSync(path, 'utf8'));
|
|
161
|
+
const deps = { ...(composer.require || {}), ...(composer['require-dev'] || {}) };
|
|
162
|
+
if (deps['laravel/framework']) stacks.add('laravel');
|
|
163
|
+
} catch { /* an unreadable composer.json is still PHP */ }
|
|
164
|
+
}
|
|
155
165
|
if (entry.name === 'go.mod') stacks.add('go');
|
|
156
166
|
if (entry.name === 'Cargo.toml') stacks.add('rust');
|
|
157
167
|
if (entry.name === 'pubspec.yaml') stacks.add('flutter');
|
|
@@ -116,9 +116,9 @@ function nativeOutput(target, eventName, context = '', notice = null, compaction
|
|
|
116
116
|
* checkout, the model is told — every message, while it lasts — to do new work in a worktree. Local
|
|
117
117
|
* journal only, no network (the memory's promise stands). `.no-worktree-isolation` turns it off.
|
|
118
118
|
*/
|
|
119
|
-
export function isolationContext({ project, here, sessionId, target }) {
|
|
119
|
+
export function isolationContext({ project, here, sessionId, target, memoryRoot }) {
|
|
120
120
|
if (existsSync(join(project, '.rsc', '.no-worktree-isolation'))) return '';
|
|
121
|
-
const others = otherActiveSessions({ cwd: project, worktreeCwd: here, sessionId, target });
|
|
121
|
+
const others = otherActiveSessions({ cwd: project, worktreeCwd: here, sessionId, target, memoryRoot });
|
|
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}). ` +
|
|
@@ -176,7 +176,8 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
176
176
|
// nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
|
|
177
177
|
// nothing.
|
|
178
178
|
const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
|
|
179
|
-
const isolation = event === 'request'
|
|
179
|
+
const isolation = event === 'request'
|
|
180
|
+
? isolationContext({ project, here, sessionId: id, target, memoryRoot: captured.memoryRoot }) : '';
|
|
180
181
|
return {
|
|
181
182
|
output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
|
|
182
183
|
capture: captured,
|
|
@@ -46,9 +46,12 @@ function cleanId(value, prefix = 'session') {
|
|
|
46
46
|
return `${prefix}-${hash}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// `input` feeds stdin, so one process can answer for many files or commits. Every git process costs
|
|
50
|
+
// 50–80 ms on Windows, and one per file and per commit pushed the hooks past Claude Code's 5 s (#332).
|
|
51
|
+
function git(cwd, args, input = null) {
|
|
50
52
|
try {
|
|
51
|
-
|
|
53
|
+
const stdin = input === null ? { stdio: ['ignore', 'pipe', 'ignore'] } : { input, stdio: ['pipe', 'pipe', 'ignore'] };
|
|
54
|
+
return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', ...stdin, maxBuffer: 16 * 1024 * 1024 }).trim() };
|
|
52
55
|
} catch {
|
|
53
56
|
return { ok: false, out: '' };
|
|
54
57
|
}
|
|
@@ -168,22 +171,43 @@ function parseStatus(output) {
|
|
|
168
171
|
return files;
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
function fingerprintOne(worktree, path) {
|
|
175
|
+
const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
|
|
176
|
+
return result.ok ? result.out : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Every file in one `git hash-object --stdin-paths`. One path it cannot read fails the whole batch, so
|
|
180
|
+
// only regular files go in (a deleted one has no fingerprint, as before), and a line opening with `"`
|
|
181
|
+
// is read as C-quoted, so such a name is hashed on its own. If the batch still fails — a file removed
|
|
182
|
+
// between the check and the hash — each file is hashed alone, exactly as it always was.
|
|
171
183
|
function fingerprintFiles(worktree, files) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
175
|
-
})
|
|
184
|
+
const fingerprints = Object.fromEntries(files.map((path) => [path, null]));
|
|
185
|
+
const readable = files.filter((path) => {
|
|
186
|
+
try { return statSync(join(worktree, path)).isFile(); } catch { return false; }
|
|
187
|
+
});
|
|
188
|
+
const batch = readable.filter((path) => !path.startsWith('"'));
|
|
189
|
+
const result = batch.length
|
|
190
|
+
? git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${batch.join('\n')}\n`) : { ok: false, out: '' };
|
|
191
|
+
const hashes = result.ok ? result.out.split('\n') : [];
|
|
192
|
+
const batched = hashes.length === batch.length ? new Map(batch.map((path, index) => [path, hashes[index]])) : new Map();
|
|
193
|
+
for (const path of readable) fingerprints[path] = batched.get(path) ?? fingerprintOne(worktree, path);
|
|
194
|
+
return fingerprints;
|
|
176
195
|
}
|
|
177
196
|
|
|
178
197
|
// `📥 docs(auto): sync desde rsc/knowledge` — written by knowledge-sync.mjs when it imports the team's
|
|
179
198
|
// documents. Authored locally, but its content is other people's.
|
|
180
199
|
const KNOWLEDGE_IMPORT = /^\S*\s*docs\(auto\): sync desde /u;
|
|
181
200
|
|
|
182
|
-
|
|
201
|
+
// Where the agent is — branch and worktree — and nothing else: two processes, not a full snapshot.
|
|
202
|
+
function position(cwd) {
|
|
183
203
|
const top = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
184
|
-
if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd)
|
|
185
|
-
|
|
186
|
-
|
|
204
|
+
if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd) };
|
|
205
|
+
return { git: true, branch: cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255), worktree: resolve(top.out) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function snapshot(cwd, baselineHead = null) {
|
|
209
|
+
const { git: inGit, branch, worktree } = position(cwd);
|
|
210
|
+
if (!inGit) return { git: false, branch, worktree, head: null, files: [], dirty: [], committed: [], fingerprints: {}, commits: [] };
|
|
187
211
|
const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
|
|
188
212
|
const dirty = parseStatus(git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']).out);
|
|
189
213
|
let committed = [];
|
|
@@ -200,17 +224,17 @@ function snapshot(cwd, baselineHead = null) {
|
|
|
200
224
|
// merged main and resolved five conflicts was recorded as «no commits, files: none»). So merges on
|
|
201
225
|
// the first-parent chain stay, and from a merge only what the session resolved counts — the
|
|
202
226
|
// combined diff (`--cc`), never everything the merge brought in.
|
|
203
|
-
|
|
227
|
+
//
|
|
228
|
+
// All the commits go through one `diff-tree --stdin`, not one process each (#332). `--cc` changes
|
|
229
|
+
// only what a merge reports and `--root` only a root commit, so one set of flags covers them all.
|
|
230
|
+
const own = git(cwd, ['log', '--first-parent', '--format=%H%x09%s', `${baselineHead}..${head}`]).out
|
|
204
231
|
.split('\n').filter(Boolean).map((line) => line.split('\t'))
|
|
205
|
-
.filter(([,
|
|
232
|
+
.filter(([, subject = '']) => !KNOWLEDGE_IMPORT.test(subject));
|
|
206
233
|
commits = own.map(([sha]) => cleanString(sha, 64)).filter(Boolean);
|
|
207
|
-
const merges = new Set(own.filter(([, parents = '']) => parents.trim().split(/\s+/).length > 1).map(([sha]) => sha));
|
|
208
234
|
const touched = new Set();
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
: ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', '--root', sha];
|
|
213
|
-
for (const item of git(cwd, args).out.split('\0')) {
|
|
235
|
+
if (commits.length) {
|
|
236
|
+
const names = git(cwd, ['diff-tree', '--stdin', '--no-commit-id', '--name-only', '-r', '-z', '--root', '--cc'], `${commits.join('\n')}\n`);
|
|
237
|
+
for (const item of names.out.split('\0')) {
|
|
214
238
|
const clean = cleanString(item, 1000);
|
|
215
239
|
if (clean) touched.add(clean);
|
|
216
240
|
}
|
|
@@ -370,7 +394,8 @@ export function otherActiveSessions(input = {}) {
|
|
|
370
394
|
const top = git(resolve(input.worktreeCwd || cwd), ['rev-parse', '--show-toplevel']);
|
|
371
395
|
if (!top.ok) return [];
|
|
372
396
|
const here = resolve(top.out);
|
|
373
|
-
|
|
397
|
+
// `memoryRoot`: the store this event's capture already chose, so it is not chosen twice.
|
|
398
|
+
const store = input.memoryRoot ? { root: input.memoryRoot } : chooseMemoryRoot(cwd);
|
|
374
399
|
if (!existsSync(store.root)) return [];
|
|
375
400
|
const now = new Date(input.now || Date.now()).getTime();
|
|
376
401
|
const windowMs = Number.isFinite(input.windowMs) ? input.windowMs : ACTIVE_WINDOW_MS;
|
|
@@ -467,7 +492,10 @@ export function capture(input = {}) {
|
|
|
467
492
|
else if (fileHint && !relativeSlash(project, fileHint).startsWith('..')) movedTo = null; // back in the main checkout
|
|
468
493
|
else if (movedTo && existsSync(movedTo)) here = movedTo;
|
|
469
494
|
else movedTo = null;
|
|
470
|
-
|
|
495
|
+
// One full snapshot per event (#332). With an anchor, presence needs only the branch and the worktree.
|
|
496
|
+
// Without one, this snapshot founds the anchor and, when the baseline is this very HEAD, is already
|
|
497
|
+
// everything the second one would find: no session commits yet, the same dirty files.
|
|
498
|
+
const firstSnapshot = anchor ? position(here) : snapshot(here, null);
|
|
471
499
|
// Presence: on every event, before any early return — the point is to be seen before there is work.
|
|
472
500
|
const ending = ['end', 'sessionEnd', 'stop'].includes(input.event);
|
|
473
501
|
if (ending) rmSync(join(store.root, 'presence', `${storageId}.json`), { force: true });
|
|
@@ -477,6 +505,7 @@ export function capture(input = {}) {
|
|
|
477
505
|
worktree: cleanString(firstSnapshot.worktree, 1000), updatedAt: now,
|
|
478
506
|
});
|
|
479
507
|
}
|
|
508
|
+
const founded = !anchor;
|
|
480
509
|
if (!anchor) {
|
|
481
510
|
anchor = {
|
|
482
511
|
sessionId,
|
|
@@ -492,9 +521,12 @@ export function capture(input = {}) {
|
|
|
492
521
|
anchor = movedTo ? { ...rest, movedTo } : rest;
|
|
493
522
|
atomicJson(anchorPath, anchor);
|
|
494
523
|
}
|
|
495
|
-
|
|
524
|
+
// The store travels back with the answer so the isolation check of the same event does not choose it
|
|
525
|
+
// again: that is five more git processes per message.
|
|
526
|
+
const memoryRoot = store.root;
|
|
527
|
+
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false, memoryRoot };
|
|
496
528
|
|
|
497
|
-
const repo = snapshot(here, anchor.baselineHead);
|
|
529
|
+
const repo = founded && anchor.baselineHead === firstSnapshot.head ? firstSnapshot : snapshot(here, anchor.baselineHead);
|
|
498
530
|
// Count what CHANGED, not which tool changed it. `editDelta` is +1 per Edit/Write tool event, and
|
|
499
531
|
// on its own it was blind to everything done through the shell — a heredoc, `sed -i`, a script, a
|
|
500
532
|
// formatter — which is how automated sessions work. The session that found this had touched dozens
|
|
@@ -515,7 +547,7 @@ export function capture(input = {}) {
|
|
|
515
547
|
const dirtyChanged = Object.entries(baselineFingerprints)
|
|
516
548
|
.some(([path, fingerprint]) => repo.fingerprints[path] !== fingerprint);
|
|
517
549
|
const hasWork = Boolean(input.force === true || existing || editCount || newDirtyPath || dirtyChanged || repo.commits.length);
|
|
518
|
-
if (!hasWork) return { record: null, path: null, notice: null, compactionHint: false };
|
|
550
|
+
if (!hasWork) return { record: null, path: null, notice: null, compactionHint: false, memoryRoot };
|
|
519
551
|
|
|
520
552
|
const records = readRecords(store.root);
|
|
521
553
|
const completed = ['end', 'sessionEnd', 'stop'].includes(input.event) ? now : (existing?.timestamps?.completedAt || null);
|
|
@@ -548,7 +580,7 @@ export function capture(input = {}) {
|
|
|
548
580
|
concurrent: activeConcurrent(records, sessionId, target, repo.worktree, now),
|
|
549
581
|
};
|
|
550
582
|
const errors = validateSessionRecord(record);
|
|
551
|
-
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
|
|
583
|
+
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors, memoryRoot };
|
|
552
584
|
atomicJson(recordPath, record);
|
|
553
585
|
existing = record;
|
|
554
586
|
// Once per session, not once per turn. `editCount` only ever grows, so `editCount >= threshold`
|
|
@@ -577,6 +609,7 @@ export function capture(input = {}) {
|
|
|
577
609
|
path: recordPath,
|
|
578
610
|
notice: consumeNotice(store),
|
|
579
611
|
compactionHint,
|
|
612
|
+
memoryRoot,
|
|
580
613
|
};
|
|
581
614
|
}
|
|
582
615
|
|
|
@@ -664,7 +697,8 @@ export function resume(input = {}) {
|
|
|
664
697
|
const now = iso(input.now);
|
|
665
698
|
prune(store.root, now, config);
|
|
666
699
|
const here = resolve(input.worktreeCwd || cwd);
|
|
667
|
-
|
|
700
|
+
// Only the branch and the worktree are compared: no status, no fingerprints (#317, #332).
|
|
701
|
+
const current = position(here);
|
|
668
702
|
const records = readRecords(store.root).filter((record) => validAnchor(here, record))
|
|
669
703
|
.sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
|
|
670
704
|
let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
|