@ericrisco/rsc 3.0.6 → 3.0.8

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.
@@ -1,5 +1,5 @@
1
1
  import {
2
- appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
2
+ appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
3
3
  } from 'node:fs';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -83,21 +83,27 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
83
83
  return { root: join(project, '.rsc', 'memory'), kind: 'local-state', reason: 'without-git', git: false };
84
84
  }
85
85
 
86
+ // The harness itself commits `worklog/.gitkeep` so the folder exists in every clone. A placeholder
87
+ // is not the team committing its worklog, and reading it as one warned every teammate in every clone
88
+ // about a tracked worklog that was an empty file (team simulation D13).
86
89
  const worklog = join(project, '02-DOCS', 'raw', 'worklog');
87
- if (existsSync(worklog)) {
88
- const tracked = git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out;
89
- if (!tracked) {
90
- const candidate = join(worklog, '.rsc-memory');
91
- if (ensureExcluded(project, candidate)) {
92
- return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
93
- }
90
+ const local = join(project, '.rsc', 'memory');
91
+ const trackedFiles = existsSync(worklog)
92
+ ? git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out.split('\n').filter(Boolean) : [];
93
+ const trackedNotes = trackedFiles.filter((file) => !/(^|\/)\.(gitkeep|keep)$/u.test(file));
94
+ // A clone whose journal already lives in .rsc/memory keeps it there: moving would orphan its history.
95
+ const placeholderOnly = trackedFiles.length > 0 && trackedNotes.length === 0;
96
+ const settled = placeholderOnly && existsSync(join(local, 'sessions'));
97
+ if (existsSync(worklog) && !trackedNotes.length && !settled) {
98
+ const candidate = join(worklog, '.rsc-memory');
99
+ if (ensureExcluded(project, candidate)) {
100
+ return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
94
101
  }
95
102
  }
96
103
 
97
- const local = join(project, '.rsc', 'memory');
98
104
  if (ensureExcluded(project, local)) {
99
- const trackedWorklog = existsSync(worklog) && Boolean(git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out);
100
- return { root: local, kind: 'local-state', reason: trackedWorklog ? 'tracked-worklog' : 'no-wiki', git: true };
105
+ const reason = trackedNotes.length ? 'tracked-worklog' : settled ? 'placeholder-worklog' : 'no-wiki';
106
+ return { root: local, kind: 'local-state', reason, git: true };
101
107
  }
102
108
 
103
109
  const gitRoot = git(project, ['rev-parse', '--git-path', 'rsc-memory']).out;
@@ -108,7 +114,7 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
108
114
  }
109
115
 
110
116
  function ensureStore(root) {
111
- for (const dir of ['sessions', 'anchors', 'lessons']) mkdirSync(join(root, dir), { recursive: true });
117
+ for (const dir of ['sessions', 'anchors', 'lessons', 'presence']) mkdirSync(join(root, dir), { recursive: true });
112
118
  }
113
119
 
114
120
  function atomicJson(path, value) {
@@ -169,9 +175,13 @@ function fingerprintFiles(worktree, files) {
169
175
  }));
170
176
  }
171
177
 
178
+ // `📥 docs(auto): sync desde rsc/knowledge` — written by knowledge-sync.mjs when it imports the team's
179
+ // documents. Authored locally, but its content is other people's.
180
+ const KNOWLEDGE_IMPORT = /^\S*\s*docs\(auto\): sync desde /u;
181
+
172
182
  function snapshot(cwd, baselineHead = null) {
173
183
  const top = git(cwd, ['rev-parse', '--show-toplevel']);
174
- if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], fingerprints: {}, commits: [] };
184
+ if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], dirty: [], committed: [], fingerprints: {}, commits: [] };
175
185
  const worktree = resolve(top.out);
176
186
  const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
177
187
  const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
@@ -179,11 +189,36 @@ function snapshot(cwd, baselineHead = null) {
179
189
  let committed = [];
180
190
  let commits = [];
181
191
  if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
182
- committed = git(cwd, ['diff', '--name-only', '-z', `${baselineHead}..${head}`]).out.split('\0').map((item) => cleanString(item, 1000)).filter(Boolean);
183
- commits = git(cwd, ['log', '--format=%H', `${baselineHead}..${head}`]).out.split('\n').map((item) => cleanString(item, 64)).filter(Boolean);
192
+ // The session's OWN commits: the first-parent chain, merges left out. `baseline..head` after a
193
+ // `git merge origin/main` also walks every teammate commit the merge brought in, and the memory
194
+ // then told the next session it had done their work and should carry out their next step (team
195
+ // simulation, 2026-10-07). The files follow from those commits only — never `diff baseline..head`,
196
+ // which counts everything the merge changed. The knowledge-sync import commit is machine-made and
197
+ // carries other people's feature documents, so it is not the session's work either.
198
+ //
199
+ // A merge the session itself made IS its work, though (second team simulation: a session that
200
+ // merged main and resolved five conflicts was recorded as «no commits, files: none»). So merges on
201
+ // the first-parent chain stay, and from a merge only what the session resolved counts — the
202
+ // 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
204
+ .split('\n').filter(Boolean).map((line) => line.split('\t'))
205
+ .filter(([, , subject = '']) => !KNOWLEDGE_IMPORT.test(subject));
206
+ 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
+ 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')) {
214
+ const clean = cleanString(item, 1000);
215
+ if (clean) touched.add(clean);
216
+ }
217
+ }
218
+ committed = [...touched];
184
219
  }
185
220
  const files = [...new Set([...dirty, ...committed])].sort();
186
- return { git: true, branch, worktree, head, files, fingerprints: fingerprintFiles(worktree, dirty), commits };
221
+ return { git: true, branch, worktree, head, files, dirty, committed, fingerprints: fingerprintFiles(worktree, dirty), commits };
187
222
  }
188
223
 
189
224
  function ledgerSnapshot(cwd) {
@@ -228,19 +263,76 @@ function prune(root, now, config) {
228
263
  const updated = value?.timestamps?.updatedAt || value?.startedAt;
229
264
  if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
230
265
  }
266
+ // Presence only means anything inside the active window; a day is generous and keeps the dir small.
267
+ const presenceCutoff = new Date(now).getTime() - 86400000;
268
+ for (const path of presenceFiles(root)) {
269
+ const value = readJson(path);
270
+ if (!value?.updatedAt || new Date(value.updatedAt).getTime() < presenceCutoff) rmSync(path, { force: true });
271
+ }
272
+ }
273
+
274
+ function presenceFiles(root) {
275
+ const dir = join(root, 'presence');
276
+ if (!existsSync(dir)) return [];
277
+ return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
278
+ }
279
+
280
+ const PRESENCE_FIELDS = ['schemaVersion', 'sessionId', 'target', 'branch', 'worktree', 'updatedAt'];
281
+
282
+ /**
283
+ * "A session is here", written on every event from the first one (team simulation W2). The journal
284
+ * only writes a RECORD once there is work, so a session that had just started — or was still reading
285
+ * before its first edit — was invisible, and a second session opened twenty seconds later was never
286
+ * told to isolate. A record cannot carry this: resume would hand that empty record to the next
287
+ * session as "your continuation". So presence is its own tiny file: who and where, never what — no
288
+ * prompt, no file, no content (P3) — and it is removed when the session ends.
289
+ */
290
+ function writePresence(root, storageId, presence) {
291
+ const value = Object.fromEntries(PRESENCE_FIELDS.map((key) => [key, presence[key] ?? null]));
292
+ if (Object.values(value).some((item) => secretShaped(item))) return;
293
+ atomicJson(join(root, 'presence', `${storageId}.json`), value);
294
+ }
295
+
296
+ function readPresence(root) {
297
+ return presenceFiles(root).map(readJson).filter((item) => item && item.schemaVersion === 1
298
+ && typeof item.sessionId === 'string' && typeof item.updatedAt === 'string'
299
+ && Object.keys(item).every((key) => PRESENCE_FIELDS.includes(key)));
300
+ }
301
+
302
+ // Where a session is, in the parent project's terms: the worktree under `<project>/.worktrees/` that
303
+ // holds `path`, or null when `path` is not inside one.
304
+ function nestedWorktreeOf(project, path) {
305
+ if (!path) return null;
306
+ const rel = relativeSlash(project, path);
307
+ if (!rel.startsWith('.worktrees/')) return null;
308
+ // git needs a directory to stand in: the file's own folder, or the nearest one that exists yet.
309
+ let probe = existsSync(path) && statSync(path).isDirectory() ? path : dirname(path);
310
+ while (!existsSync(probe)) {
311
+ const parent = dirname(probe);
312
+ if (parent === probe) return null;
313
+ probe = parent;
314
+ }
315
+ const top = git(probe, ['rev-parse', '--show-toplevel']);
316
+ if (!top.ok) return null;
317
+ const worktree = resolve(top.out);
318
+ return relativeSlash(project, worktree).startsWith('.worktrees/') ? worktree : null;
231
319
  }
232
320
 
233
321
  function noticeText(info) {
322
+ if (info.reason === 'placeholder-worklog') return null; // nothing the person needs to know
234
323
  if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
235
324
  if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
236
325
  if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
237
326
  if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
238
- return 'rsc memory: no wiki worklog found; using ignored local state.';
327
+ // The ordinary case (no wiki worklog, local state) is not news: said to every new clone it was noise
328
+ // (second team simulation), like the placeholder case above.
329
+ return null;
239
330
  }
240
331
 
241
332
  function consumeNotice(info) {
242
333
  const marker = join(info.root, `.notice-${info.reason}`);
243
334
  if (existsSync(marker)) return null;
335
+ if (!noticeText(info)) return null;
244
336
  writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
245
337
  return noticeText(info);
246
338
  }
@@ -267,8 +359,10 @@ export const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
267
359
  * never WHAT is in the project (constitution P3), reads only the local journal (no network, no
268
360
  * content), and its worst failure is one worktree too many or too few.
269
361
  *
270
- * A session that only talks has no record — the journal only writes when there is work — and that is
271
- * the right blind spot: the harm this prevents is switching branches under a session that is EDITING.
362
+ * A session that only talks has no RECORD — the journal only writes when there is work — but it has a
363
+ * PRESENCE from its first event, so a session opened seconds after another one is still told to
364
+ * isolate (team simulation W2). Sessions are counted where they are NOW: one that moved into
365
+ * `.worktrees/<b>/` no longer counts in the main checkout.
272
366
  */
273
367
  export function otherActiveSessions(input = {}) {
274
368
  try {
@@ -280,12 +374,30 @@ export function otherActiveSessions(input = {}) {
280
374
  if (!existsSync(store.root)) return [];
281
375
  const now = new Date(input.now || Date.now()).getTime();
282
376
  const windowMs = Number.isFinite(input.windowMs) ? input.windowMs : ACTIVE_WINDOW_MS;
283
- return readRecords(store.root)
284
- .filter((r) => r.worktree === here)
285
- .filter((r) => !(r.sessionId === input.sessionId && r.target === input.target))
286
- .filter((r) => !r.timestamps?.completedAt)
287
- .filter((r) => now - new Date(r.timestamps?.updatedAt || 0).getTime() <= windowMs)
288
- .map((r) => ({ target: r.target, sessionId: r.sessionId, branch: r.branch, updatedAt: r.timestamps.updatedAt }));
377
+ // Each session once, at its LATEST known position: its record (work) or its presence (any event),
378
+ // whichever is newer. A session that moved into a worktree is no longer here, whatever an older
379
+ // record of it says.
380
+ const latest = new Map();
381
+ for (const r of readRecords(store.root)) {
382
+ latest.set(`${r.target}--${r.sessionId}`, {
383
+ target: r.target, sessionId: r.sessionId, branch: r.branch, worktree: r.worktree,
384
+ updatedAt: r.timestamps?.updatedAt, completedAt: r.timestamps?.completedAt || null,
385
+ });
386
+ }
387
+ for (const p of readPresence(store.root)) {
388
+ const key = `${p.target}--${p.sessionId}`;
389
+ const known = latest.get(key);
390
+ const newer = !known || new Date(p.updatedAt).getTime() >= new Date(known.updatedAt || 0).getTime();
391
+ // A presence newer than the record's close means the session came back (resumed by id).
392
+ if (newer) latest.set(key, { ...p, completedAt: known?.completedAt && new Date(known.completedAt).getTime() >= new Date(p.updatedAt).getTime() ? known.completedAt : null });
393
+ }
394
+ const self = (s) => s.sessionId === cleanId(input.sessionId, 'session') && s.target === input.target;
395
+ return [...latest.values()]
396
+ .filter((s) => s.worktree === here)
397
+ .filter((s) => !self(s))
398
+ .filter((s) => !s.completedAt)
399
+ .filter((s) => now - new Date(s.updatedAt || 0).getTime() <= windowMs)
400
+ .map((s) => ({ target: s.target, sessionId: s.sessionId, branch: s.branch, updatedAt: s.updatedAt }));
289
401
  } catch {
290
402
  return [];
291
403
  }
@@ -322,7 +434,7 @@ export function capture(input = {}) {
322
434
  // The second has to be asked of git from where the agent actually works: asked from the harness,
323
435
  // every worktree nested under it came back as the harness itself, and a sibling's record then
324
436
  // "matched exactly". See GHSA-8gjp-3r7c-33f4.
325
- const here = resolve(input.worktreeCwd || cwd);
437
+ let here = resolve(input.worktreeCwd || cwd);
326
438
  const config = settings(input.settings);
327
439
  if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
328
440
  const store = chooseMemoryRoot(cwd);
@@ -338,7 +450,33 @@ export function capture(input = {}) {
338
450
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
339
451
  let anchor = readJson(anchorPath);
340
452
  let existing = readJson(recordPath);
453
+ // A session that moved into `.worktrees/<b>/` is THERE, also on the events that do not say so.
454
+ // An edit names its file — the first sign of the move when the agent writes by absolute path and
455
+ // its cwd never changed — and `Stop`/`SessionEnd` name nothing, so the move is remembered on the
456
+ // anchor until the session edits the main checkout again. Without this the record stayed on the
457
+ // main checkout, open, and every later session there was told for half an hour that someone was
458
+ // working beside it (team simulation W1).
459
+ const projectTop = git(cwd, ['rev-parse', '--show-toplevel']);
460
+ const project = projectTop.ok ? resolve(projectTop.out) : cwd;
461
+ const fileHint = typeof input.filePath === 'string' && isAbsolute(input.filePath) ? resolve(input.filePath) : null;
462
+ let movedTo = anchor?.movedTo || null;
463
+ const fromFile = nestedWorktreeOf(project, fileHint);
464
+ if (fromFile) here = fromFile;
465
+ const nestedHere = nestedWorktreeOf(project, here);
466
+ if (nestedHere) movedTo = nestedHere;
467
+ else if (fileHint && !relativeSlash(project, fileHint).startsWith('..')) movedTo = null; // back in the main checkout
468
+ else if (movedTo && existsSync(movedTo)) here = movedTo;
469
+ else movedTo = null;
341
470
  const firstSnapshot = snapshot(here, null);
471
+ // Presence: on every event, before any early return — the point is to be seen before there is work.
472
+ const ending = ['end', 'sessionEnd', 'stop'].includes(input.event);
473
+ if (ending) rmSync(join(store.root, 'presence', `${storageId}.json`), { force: true });
474
+ else {
475
+ writePresence(store.root, storageId, {
476
+ schemaVersion: 1, sessionId, target, branch: cleanString(firstSnapshot.branch, 255),
477
+ worktree: cleanString(firstSnapshot.worktree, 1000), updatedAt: now,
478
+ });
479
+ }
342
480
  if (!anchor) {
343
481
  anchor = {
344
482
  sessionId,
@@ -346,8 +484,13 @@ export function capture(input = {}) {
346
484
  baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
347
485
  baselineFiles: firstSnapshot.files,
348
486
  baselineFingerprints: firstSnapshot.fingerprints,
487
+ ...(movedTo ? { movedTo } : {}),
349
488
  };
350
489
  atomicJson(anchorPath, anchor);
490
+ } else if ((anchor.movedTo || null) !== movedTo) {
491
+ const { movedTo: _previous, ...rest } = anchor;
492
+ anchor = movedTo ? { ...rest, movedTo } : rest;
493
+ atomicJson(anchorPath, anchor);
351
494
  }
352
495
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
353
496
 
@@ -391,7 +534,11 @@ export function capture(input = {}) {
391
534
  worktree: cleanString(repo.worktree, 1000),
392
535
  baselineHead: cleanString(anchor.baselineHead, 64),
393
536
  head: cleanString(repo.head, 64),
394
- files: repo.files.filter((item) => !secretShaped(item)),
537
+ // What THIS session touched: what it committed, plus what is dirty now and was not dirty — or not
538
+ // this content — when it started. The plain union listed whatever an install had left untracked,
539
+ // so a session that never opened `.rsc.json` reported it as its work (E2E 2026-10-07).
540
+ files: [...new Set([...repo.committed, ...repo.dirty.filter((path) => !baselineFiles.has(path)
541
+ || baselineFingerprints[path] !== repo.fingerprints[path])])].sort().filter((item) => !secretShaped(item)),
395
542
  commits: repo.commits.filter((item) => !secretShaped(item)),
396
543
  ledger: ledgerSnapshot(cwd),
397
544
  timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
@@ -455,19 +602,46 @@ function truncateUtf8(text, maxBytes) {
455
602
  return output;
456
603
  }
457
604
 
458
- function renderContext(record, match, lessons, config) {
605
+ // WHAT the last session did, not only where it stopped: the subjects of its commits and the next step
606
+ // its feature document wrote down. Read at resume time from git and the document — never from the
607
+ // prompts, which the record promises not to keep — so the record's closed schema does not change.
608
+ function subjects(cwd, commits) {
609
+ return commits.slice(0, 8).map((sha) => {
610
+ const out = git(cwd, ['log', '-1', '--format=%h %s', sha]);
611
+ return out.ok ? cleanString(out.out, 160) : cleanString(sha.slice(0, 7), 7);
612
+ }).filter((item) => item && !secretShaped(item));
613
+ }
614
+
615
+ function nextSteps(cwd, files) {
616
+ const out = [];
617
+ for (const rel of files.filter((f) => /^02-DOCS\/wiki\/(ftd|sdd\/(specs|plans|progress))\/[^/]+\.md$/u.test(f)).slice(0, 3)) {
618
+ let body = '';
619
+ try { body = readFileSync(join(cwd, rel), 'utf8'); } catch { continue; }
620
+ const section = body.match(/^##\s*(Next|Siguiente|Próximo)[^\n]*\n+([\s\S]*?)(?=\n##\s|$)/imu)?.[2];
621
+ const first = section?.split('\n').map((l) => l.replace(/^[-*]\s*/u, '').trim()).find(Boolean);
622
+ const open = (body.match(/^- \[ \]/gmu) || []).length;
623
+ if (first || open) out.push(`${rel}: ${first ? cleanString(first, 200) : 'no next step written'}${open ? ` (${open} open)` : ''}`);
624
+ }
625
+ return out;
626
+ }
627
+
628
+ function renderContext(record, match, lessons, config, cwd = process.cwd()) {
459
629
  if (!record && !lessons.length) return '';
630
+ const done = record ? subjects(cwd, record.commits) : [];
631
+ const next = record ? nextSteps(cwd, record.files) : [];
460
632
  const lines = record ? [
461
633
  `[rsc local ${match} continuation]`,
462
- `source: ${record.target}/${record.sessionId}`,
634
+ `source: ${record.target}/${record.sessionId} (${record.timestamps.startedAt} → ${record.timestamps.updatedAt})`,
463
635
  `branch: ${record.branch || 'unavailable'}`,
464
636
  `worktree: ${record.worktree || 'unavailable'}`,
465
637
  `head: ${record.head || 'unavailable'}`,
638
+ `done: ${done.length ? done.join(' | ') : 'no commits'}`,
466
639
  `files: ${record.files.join(', ') || 'none'}`,
467
- `commits: ${record.commits.join(', ') || 'none'}`,
640
+ ...(next.length ? [`next: ${next.join(' | ')}`] : []),
468
641
  ] : ['[rsc local approved lessons]'];
469
642
  if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
470
- if (record) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
643
+ // Only when something is known: «cost=unknown toolCalls=unknown» on every resume told the model nothing.
644
+ if (record && (record.cost != null || record.toolCalls != null)) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
471
645
  if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
472
646
  for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
473
647
  return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
@@ -497,7 +671,7 @@ export function resume(input = {}) {
497
671
  if (record) match = 'nearby';
498
672
  }
499
673
  const lessons = selectedLessons(store.root, config);
500
- return { match, record, lessons, context: renderContext(record, match, lessons, config) };
674
+ return { match, record, lessons, context: renderContext(record, match, lessons, config, here) };
501
675
  }
502
676
 
503
677
  export function learn(input = {}) {
@@ -530,10 +704,11 @@ export function metricsSummary(input = {}) {
530
704
  cost: sessions.filter((row) => row.cost === null).length,
531
705
  toolCalls: sessions.filter((row) => row.toolCalls === null).length,
532
706
  };
533
- const knownTotal = {
534
- cost: sessions.reduce((sum, row) => sum + (row.cost ?? 0), 0),
535
- toolCalls: sessions.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0),
536
- };
707
+ // A sum over nothing known is unknown, not zero: doctor printed `cost: 0` for three sessions whose
708
+ // cost nobody had measured.
709
+ const knownSum = (key) => (unknown[key] === sessions.length ? null
710
+ : sessions.reduce((sum, row) => sum + (row[key] ?? 0), 0));
711
+ const knownTotal = { cost: knownSum('cost'), toolCalls: knownSum('toolCalls') };
537
712
  const total = {
538
713
  cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
539
714
  toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
@@ -9,6 +9,7 @@ import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from
9
9
  import { updateNotice } from './auto-update.mjs';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
+ import { spawnSync } from 'node:child_process';
12
13
  import { claimOnce, readHookInput } from './hook-once.mjs';
13
14
 
14
15
  const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
@@ -36,11 +37,14 @@ function hasContext7() {
36
37
  } catch { return false; }
37
38
  }
38
39
 
39
- // Has a skill audit run within the cadence? Missing stamp → never audited → due.
40
+ // Has a skill audit run within the cadence? Missing stamp → never audited → due. Onboarding writes a
41
+ // `baseline` (no audit ran yet), so the first one falls due a full cadence after install.
40
42
  function auditDue() {
41
43
  try {
42
- const { lastRun } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
43
- return (Date.now() - new Date(lastRun).getTime()) / 86400000 >= STALE_AUDIT_DAYS;
44
+ const { lastRun, baseline } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
45
+ const since = new Date(lastRun || baseline).getTime();
46
+ if (Number.isNaN(since)) return true;
47
+ return (Date.now() - since) / 86400000 >= STALE_AUDIT_DAYS;
44
48
  } catch { return true; }
45
49
  }
46
50
 
@@ -117,7 +121,38 @@ Opt out with .rsc/.no-scope-check · this notice repeats at most every ${SCOPE_W
117
121
  const profile = join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md');
118
122
  const optout = join(root, '.rsc', '.no-harness');
119
123
  const profileExists = existsSync(profile);
120
- if (!existsSync(profile) && !existsSync(optout)) {
124
+ // A teammate's clone of an onboarded project: `.rsc.json` is COMMITTED and carries the accepted
125
+ // onboarding, while the profile is personal and never committed, so it is missing in every clone by
126
+ // design. Treating that as a fresh setup told the agent, every session, to run first-install
127
+ // onboarding before the person's task (team simulation D6). There it is an offer, made once per clone.
128
+ function teammateClone() {
129
+ try {
130
+ const manifest = JSON.parse(readFileSync(join(root, '.rsc.json'), 'utf8'));
131
+ if (!manifest?.onboarding?.acceptedPlanId) return null;
132
+ const r = spawnSync('git', ['-C', root, 'ls-files', '--error-unmatch', '--', '.rsc.json'], { windowsHide: true, stdio: 'ignore' });
133
+ if (r.status !== 0) return null;
134
+ const level = manifest.onboarding.plan?.record?.technicalLevel;
135
+ return { level: typeof level === 'string' ? level : null };
136
+ } catch { return null; }
137
+ }
138
+ const clone = !existsSync(profile) && !existsSync(optout) ? teammateClone() : null;
139
+ const offered = join(root, '.rsc', '.profile-offered');
140
+ if (clone) {
141
+ if (!existsSync(offered)) {
142
+ process.stdout.write(`
143
+ ===== rsc onboarding =====
144
+ This is a clone of a project already set up with rsc. The personal profile
145
+ (02-DOCS/wiki/harness/user-profile.md) is never committed, so it is not here. Nothing is blocked.
146
+ ACTION: ask the person once, in one line, whether they want technical terms or analogies; then continue with their task.
147
+ Do not run \`init\`. If they answer, write \`technical_level: technical\` or \`technical_level: non-technical\`
148
+ to 02-DOCS/wiki/harness/user-profile.md (personal, excluded from commits). If they decline or skip it,
149
+ use ${clone.level ? `the level the project was installed with (${clone.level})` : 'analogies'} and do not ask again.
150
+ Said once per clone (.rsc/.profile-offered).
151
+ ==========================
152
+ `);
153
+ try { mkdirSync(join(root, '.rsc'), { recursive: true }); writeFileSync(offered, `${new Date().toISOString()}\n`); } catch { /* unwritable → said again next session */ }
154
+ }
155
+ } else if (!existsSync(profile) && !existsSync(optout)) {
121
156
  process.stdout.write(`
122
157
  ===== rsc onboarding =====
123
158
  Fresh setup: 02-DOCS/wiki/harness/user-profile.md is missing.
@@ -258,7 +293,7 @@ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
258
293
  if (has('.git')) {
259
294
  try {
260
295
  const T = await import('./team-safe-start.mjs');
261
- for (const said of [T.teamSafeAnnouncement(root), await T.rescueTrunkCommits(root), await T.relocateOldWorktrees(root)]) {
296
+ for (const said of [T.teamSafeAnnouncement(root), await T.rescueTrunkCommits(root), await T.relocateOldWorktrees(root), T.harnessCommitNotice?.(root)]) {
262
297
  if (said) process.stdout.write(said.startsWith('\n') ? said : `\n${said}\n`);
263
298
  }
264
299
  } catch { /* module missing → nothing to do; never worth breaking startup for */ }
@@ -0,0 +1,145 @@
1
+ // rsc shell unwrap — what a Bash command RUNS, as opposed to what it says.
2
+ //
3
+ // Shared by the PreToolUse guards (branch-guard, gitmoji-guard, danger-guard, and the sello that
4
+ // ship-guard loads). Hooks are materialized file by file under `.rsc/`, so this travels as their
5
+ // SIBLING (targets/claude.js copies it with the lifecycle files) and each guard imports it
6
+ // dynamically: without it a guard falls back to what it read before, never to a crash.
7
+ //
8
+ // E2E defect 15 (2026-10-07): `bash -c "git commit -m x"` hid a commit inside a quoted string, and
9
+ // every guard blanks quoted text so that `grep "git commit"` is not mistaken for a commit. Both are
10
+ // right, and they meet here: quoted text stays text EXCEPT where a wrapper hands it to a shell —
11
+ // `bash|sh|zsh|dash|ksh -c <string>` and `eval …` — which is unwrapped recursively. Leading
12
+ // `VAR=x`, `env`, `command`, `exec`, `sudo`, `nohup`, `nice`, `time`, `xargs` are peeled off so
13
+ // the segment starts at the real command. Not a shell parser: `$(…)`, functions, aliases and
14
+ // subshell scoping of `cd` are not modelled.
15
+
16
+ /** Heredoc bodies are text being written, not commands: `cat > notes.md <<EOF … git commit … EOF`. */
17
+ export const withoutHeredocs = (command) => command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, '');
18
+
19
+ /** The command split where the shell would run one thing after another, never inside quotes. */
20
+ export function segments(command) {
21
+ const out = [];
22
+ let cur = '';
23
+ let q = null;
24
+ for (let i = 0; i < command.length; i++) {
25
+ const c = command[i];
26
+ if (q) { cur += c; if (c === '\\' && q === '"') cur += command[++i] ?? ''; else if (c === q) q = null; continue; }
27
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
28
+ if (c === ';' || c === '\n' || c === '|' || c === '&') {
29
+ if ((c === '&' || c === '|') && command[i + 1] === c) i++;
30
+ out.push(cur); cur = ''; continue;
31
+ }
32
+ cur += c;
33
+ }
34
+ out.push(cur);
35
+ return out.filter((s) => s.trim());
36
+ }
37
+
38
+ /** Shell words of one segment, quotes removed, each with where it starts in the segment. */
39
+ export function words(segment) {
40
+ const out = [];
41
+ let i = 0;
42
+ const n = segment.length;
43
+ while (i < n) {
44
+ while (i < n && /\s/.test(segment[i])) i++;
45
+ if (i >= n) break;
46
+ const start = i;
47
+ let text = '';
48
+ while (i < n && !/\s/.test(segment[i])) {
49
+ const c = segment[i];
50
+ if (c === "'") { const e = segment.indexOf("'", i + 1); const end = e < 0 ? n : e; text += segment.slice(i + 1, end); i = end + 1; continue; }
51
+ if (c === '"') {
52
+ i++;
53
+ while (i < n && segment[i] !== '"') {
54
+ if (segment[i] === '\\' && /["\\$`]/.test(segment[i + 1] ?? '')) { text += segment[i + 1]; i += 2; continue; }
55
+ text += segment[i++];
56
+ }
57
+ i++; continue;
58
+ }
59
+ if (c === '\\') { text += segment[i + 1] ?? ''; i += 2; continue; }
60
+ text += c; i++;
61
+ }
62
+ out.push({ text, start });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ const base = (w) => w.replace(/^.*\//, '');
68
+ const ASSIGN = /^[A-Za-z_]\w*=/;
69
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
70
+ // Wrapper → the options of it that take a separate argument.
71
+ const WRAPPERS = {
72
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
73
+ command: new Set(),
74
+ exec: new Set(['-a']),
75
+ sudo: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
76
+ doas: new Set(['-u', '-C']),
77
+ nohup: new Set(),
78
+ nice: new Set(['-n']),
79
+ time: new Set(['-f', '-o']),
80
+ xargs: new Set(['-I', '-n', '-L', '-P', '-d', '-E', '-s', '-a']),
81
+ };
82
+
83
+ /**
84
+ * One segment, its wrappers peeled: `{ inner }` when it hands a string to a shell (`bash -c`,
85
+ * `eval`), else `{ rest }` — the segment from the real command on. `null` for nothing to run.
86
+ */
87
+ export function unwrap(segment) {
88
+ const w = words(segment);
89
+ let i = 0;
90
+ while (i < w.length) {
91
+ const t = w[i].text;
92
+ if (ASSIGN.test(t)) { i++; continue; }
93
+ const b = base(t);
94
+ if (b === 'command' && /^-[vV]$/.test(w[i + 1]?.text ?? '')) break; // a lookup, not a run
95
+ if (Object.hasOwn(WRAPPERS, b)) {
96
+ const takesArg = WRAPPERS[b];
97
+ i++;
98
+ while (i < w.length && w[i].text.startsWith('-') && w[i].text !== '-') {
99
+ const opt = w[i].text;
100
+ i += takesArg.has(opt) ? 2 : 1;
101
+ if (opt === '--') break;
102
+ }
103
+ continue;
104
+ }
105
+ if (b === 'eval') {
106
+ const inner = w.slice(i + 1).map((x) => x.text).join(' ');
107
+ return inner.trim() ? { inner } : null;
108
+ }
109
+ if (SHELLS.has(b)) {
110
+ for (let j = i + 1; j < w.length; j++) {
111
+ const o = w[j].text;
112
+ if (!o.startsWith('-') && !o.startsWith('+')) break; // a script file: run as is
113
+ if (o === '-o' || o === '+o') { j++; continue; }
114
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(o)) return w[j + 1] ? { inner: w[j + 1].text } : null;
115
+ }
116
+ }
117
+ break;
118
+ }
119
+ if (i >= w.length) return null;
120
+ return { rest: segment.slice(w[i].start).trim() };
121
+ }
122
+
123
+ const MAX_DEPTH = 8;
124
+
125
+ /** Every segment the shell would really run, wrappers peeled and shell strings unwrapped. */
126
+ export function expand(command, depth = 0) {
127
+ const out = [];
128
+ for (const seg of segments(withoutHeredocs(String(command ?? '')))) {
129
+ const u = unwrap(seg);
130
+ if (!u) continue;
131
+ if (u.inner !== undefined && depth < MAX_DEPTH) out.push(...expand(u.inner, depth + 1));
132
+ else out.push(u.rest ?? seg.trim());
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** The strings this command hands to a shell (`bash -c "<this>"`, `eval <this>`), one level deep. */
138
+ export function innerScripts(command) {
139
+ const out = [];
140
+ for (const seg of segments(withoutHeredocs(String(command ?? '')))) {
141
+ const u = unwrap(seg);
142
+ if (u?.inner !== undefined) out.push(u.inner);
143
+ }
144
+ return out;
145
+ }