@ericrisco/rsc 3.0.7 → 3.0.9

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.
@@ -22,6 +22,8 @@ export const PROJECT_OPT_OUTS = [
22
22
  'claudemd-check', // the root CLAUDE.md context budget
23
23
  'danger-guard', // foot-gun denial, keyed to the committed user profile
24
24
  'feature-gate', // per-turn re-injection of the SDD gate
25
+ 'ftd-close', // a landed branch's feature documents marked done by the post-merge hook
26
+ 'ftd-nudge', // once-per-session reminder: code changed and no feature document yet
25
27
  'gitmoji', // the commit-message convention
26
28
  'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
27
29
  'ship-guard', // branch/trunk discipline
package/targets/sello.mjs CHANGED
@@ -333,12 +333,19 @@ const GIT_OPTS = '(?:-{1,2}[\\w-]+(?:=\\S+)?\\s+|-[A-Za-z]\\s+\\S+\\s+)*';
333
333
  const GIT_DELIVERY = new RegExp(`\\bgit\\s+${GIT_OPTS}(?:commit|push)(?![\\w-])`);
334
334
  const GH_DELIVERY = new RegExp(`\\bgh\\s+${GIT_OPTS}pr\\s+${GIT_OPTS}(?:create|merge)(?![\\w-])`);
335
335
 
336
+ // A delivery handed to a shell as a string (`bash -c "git push"`, `eval …`) is still a delivery
337
+ // (E2E defect 15): the segments the shell really runs are judged too. Sibling import, fail-soft.
338
+ const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
339
+
336
340
  export function isDeliveryCommand(command) {
337
341
  if (typeof command !== 'string' || !command) return false;
338
342
  // Blank out quoted strings so a command that merely MENTIONS the words is not
339
343
  // mistaken for one that runs them.
340
- const bare = command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
341
- return GIT_DELIVERY.test(bare) || GH_DELIVERY.test(bare);
344
+ const delivers = (c) => {
345
+ const bare = c.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
346
+ return GIT_DELIVERY.test(bare) || GH_DELIVERY.test(bare);
347
+ };
348
+ return delivers(command) || (SH ? SH.expand(command).some(delivers) : false);
342
349
  }
343
350
 
344
351
  // ---------------------------------------------------------------- messages (P6)
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
- import { resolve, join, dirname } from 'node:path';
4
+ import { resolve, join, dirname, isAbsolute, sep } from 'node:path';
5
5
  import { capture, otherActiveSessions, resume } from './session-memory-core.mjs';
6
6
 
7
7
  const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'deepseek', 'gemini', 'opencode']);
@@ -20,16 +20,39 @@ function projectSettings(cwd) {
20
20
  // The hook payload's `cwd` is only where the tool call happened to run; inside a container of child
21
21
  // repos that is routinely a subdirectory with no harness of its own, and anchoring there scatters
22
22
  // one session's journal across children. With no harness anywhere above, the cwd stays the project.
23
- function nearestHarness(dir) {
23
+ //
24
+ // One exception, and it is the team workflow: a worktree under `<project>/.worktrees/<branch>/`
25
+ // checks out its OWN committed `.rsc.json`, so the walk stopped there and the session's later events
26
+ // went to a second store inside the worktree — while its open record stayed in the project's store
27
+ // and kept telling every later session in the main checkout that someone was working there (team
28
+ // simulation W1). A worktree the project made belongs to the project: its journal lives in the
29
+ // project's store, with the worktree recorded as WHERE the session works.
30
+ export function nearestHarness(dir) {
24
31
  let current = dir;
25
32
  for (;;) {
26
- if (existsSync(join(current, '.rsc.json'))) return current;
33
+ if (existsSync(join(current, '.rsc.json'))) return parentProjectOf(current) || current;
27
34
  const parent = dirname(current);
28
35
  if (parent === current) return dir;
29
36
  current = parent;
30
37
  }
31
38
  }
32
39
 
40
+ function parentProjectOf(dir) {
41
+ const parts = resolve(dir).split(sep);
42
+ for (let index = 1; index < parts.length; index += 1) {
43
+ if (parts[index] !== '.worktrees') continue;
44
+ const project = parts.slice(0, index).join(sep) || sep;
45
+ if (existsSync(join(project, '.rsc.json'))) return project;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function editedPath(native) {
51
+ const input = native?.tool_input || native?.toolInput || {};
52
+ const path = input.file_path || input.notebook_path || input.path || native?.file_path || null;
53
+ return typeof path === 'string' && isAbsolute(path) ? path : null;
54
+ }
55
+
33
56
  function sessionId(native, target) {
34
57
  return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
35
58
  || native?.thread_id || `${target}-${process.ppid}`;
@@ -144,6 +167,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
144
167
  editDelta: event === 'edit' ? 1 : 0,
145
168
  settings: config,
146
169
  };
170
+ if (event === 'edit' && editedPath(native)) captureInput.filePath = editedPath(native);
147
171
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
148
172
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
149
173
  const captured = capture(captureInput);
@@ -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,54 @@ 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
+ // A document whose frontmatter says `status: done` is finished work, not a next step. On a closed
616
+ // default branch the close travels by rsc/knowledge and is not in the checkout yet (knowledge-sync
617
+ // `closeLanded`), so the exchange branch's copy is asked too — a local ref, no network.
618
+ const isDone = (text) => /^status:[ \t]*done[ \t]*$/mu.test(String(text || '').replace(/\r\n/gu, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/u)?.[1] || '');
619
+
620
+ function nextSteps(cwd, files) {
621
+ const out = [];
622
+ for (const rel of files.filter((f) => /^02-DOCS\/wiki\/(ftd|sdd\/(specs|plans|progress))\/[^/]+\.md$/u.test(f)).slice(0, 3)) {
623
+ let body = '';
624
+ try { body = readFileSync(join(cwd, rel), 'utf8'); } catch { continue; }
625
+ if (isDone(body)) continue;
626
+ const exchange = git(cwd, ['show', `refs/remotes/origin/rsc/knowledge:${rel}`]);
627
+ if (exchange.ok && isDone(exchange.out)) continue;
628
+ const section = body.match(/^##\s*(Next|Siguiente|Próximo)[^\n]*\n+([\s\S]*?)(?=\n##\s|$)/imu)?.[2];
629
+ const first = section?.split('\n').map((l) => l.replace(/^[-*]\s*/u, '').trim()).find(Boolean);
630
+ const open = (body.match(/^- \[ \]/gmu) || []).length;
631
+ if (first || open) out.push(`${rel}: ${first ? cleanString(first, 200) : 'no next step written'}${open ? ` (${open} open)` : ''}`);
632
+ }
633
+ return out;
634
+ }
635
+
636
+ function renderContext(record, match, lessons, config, cwd = process.cwd()) {
459
637
  if (!record && !lessons.length) return '';
638
+ const done = record ? subjects(cwd, record.commits) : [];
639
+ const next = record ? nextSteps(cwd, record.files) : [];
460
640
  const lines = record ? [
461
641
  `[rsc local ${match} continuation]`,
462
- `source: ${record.target}/${record.sessionId}`,
642
+ `source: ${record.target}/${record.sessionId} (${record.timestamps.startedAt} → ${record.timestamps.updatedAt})`,
463
643
  `branch: ${record.branch || 'unavailable'}`,
464
644
  `worktree: ${record.worktree || 'unavailable'}`,
465
645
  `head: ${record.head || 'unavailable'}`,
646
+ `done: ${done.length ? done.join(' | ') : 'no commits'}`,
466
647
  `files: ${record.files.join(', ') || 'none'}`,
467
- `commits: ${record.commits.join(', ') || 'none'}`,
648
+ ...(next.length ? [`next: ${next.join(' | ')}`] : []),
468
649
  ] : ['[rsc local approved lessons]'];
469
650
  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'}`);
651
+ // Only when something is known: «cost=unknown toolCalls=unknown» on every resume told the model nothing.
652
+ if (record && (record.cost != null || record.toolCalls != null)) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
471
653
  if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
472
654
  for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
473
655
  return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
@@ -497,7 +679,7 @@ export function resume(input = {}) {
497
679
  if (record) match = 'nearby';
498
680
  }
499
681
  const lessons = selectedLessons(store.root, config);
500
- return { match, record, lessons, context: renderContext(record, match, lessons, config) };
682
+ return { match, record, lessons, context: renderContext(record, match, lessons, config, here) };
501
683
  }
502
684
 
503
685
  export function learn(input = {}) {
@@ -530,10 +712,11 @@ export function metricsSummary(input = {}) {
530
712
  cost: sessions.filter((row) => row.cost === null).length,
531
713
  toolCalls: sessions.filter((row) => row.toolCalls === null).length,
532
714
  };
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
- };
715
+ // A sum over nothing known is unknown, not zero: doctor printed `cost: 0` for three sessions whose
716
+ // cost nobody had measured.
717
+ const knownSum = (key) => (unknown[key] === sessions.length ? null
718
+ : sessions.reduce((sum, row) => sum + (row[key] ?? 0), 0));
719
+ const knownTotal = { cost: knownSum('cost'), toolCalls: knownSum('toolCalls') };
537
720
  const total = {
538
721
  cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
539
722
  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 */ }