@ericrisco/rsc 3.7.2 → 3.7.4

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.7.2",
2
+ "version": "3.7.4",
3
3
  "counts": {
4
4
  "skills": 274,
5
5
  "agents": 34,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
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": {
@@ -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' ? isolationContext({ project, here, sessionId: id, target }) : '';
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
- function git(cwd, args) {
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
- return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
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
- return Object.fromEntries(files.map((path) => {
173
- const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
174
- return [path, result.ok ? result.out : null];
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
- function snapshot(cwd, baselineHead = null) {
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), head: null, files: [], dirty: [], committed: [], fingerprints: {}, commits: [] };
185
- const worktree = resolve(top.out);
186
- const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
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
- const own = git(cwd, ['log', '--first-parent', '--format=%H%x09%P%x09%s', `${baselineHead}..${head}`]).out
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(([, , subject = '']) => !KNOWLEDGE_IMPORT.test(subject));
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
- for (const sha of commits) {
210
- const args = merges.has(sha)
211
- ? ['diff-tree', '--no-commit-id', '--name-only', '--cc', '-z', sha]
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
- const store = chooseMemoryRoot(cwd);
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
- const firstSnapshot = snapshot(here, null);
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
- if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
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
- const current = snapshot(here, null);
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;