@ericrisco/rsc 3.0.7 → 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,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Knowledge sync: `01-TOOLS/` and `02-DOCS/` stay the same on every machine of a team without anybody
3
- // thinking about git. What you change goes up when your turn ends; what others change comes down
4
- // before your next message.
2
+ // Knowledge sync: `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` (never `01-TOOLS/_TEMPLATE/`
3
+ // nor the personal profile) stay the same on every machine of a team without anybody thinking about
4
+ // git. What you change goes up when your turn ends; what others change comes down before your next
5
+ // message — into a working branch, never into a default branch closed for the agent.
5
6
  //
6
7
  // It never touches the default branch (team-safe-default D: assume it is protected). Knowledge travels
7
8
  // between people through one exchange branch on the remote, `rsc/knowledge`: up to it, down from it,
@@ -12,7 +13,8 @@
12
13
  // On by default, and that is a decision with a price, so the price is kept small on purpose:
13
14
  // - it only ever touches KNOWLEDGE paths, and never the personal profile;
14
15
  // - it never pushes a commit that is not its own — your unpushed code stays unpushed;
15
- // - every commit it makes says `[skip ci]`, so a wiki edit does not trigger a deploy;
16
+ // - every commit it puts on `rsc/knowledge` says `[skip ci]`, so a wiki edit does not trigger a
17
+ // deploy (the local copy on your branch does not: see `commitLocal`);
16
18
  // - the first turn says it is on and how to turn it off (`rsc knowledge-sync off`, or
17
19
  // `.rsc/.no-knowledge-sync`, a PROJECT switch that travels in `.rsc.json`).
18
20
  //
@@ -27,7 +29,8 @@
27
29
  // - what touches THE NETWORK — fetch, push — runs detached, and never touches the working tree or
28
30
  // the index: the push is replayed on a throwaway index on top of `rsc/knowledge` (`commit-tree`),
29
31
  // which is also what lets it go up from whatever branch you are on without carrying that branch.
30
- // The cost: somebody else's change reaches you one message late.
32
+ // The cost: somebody else's change reaches you one message late — unless the last fetch is old, and
33
+ // then one short foreground fetch comes first (`onRequest`).
31
34
  //
32
35
  // Standalone on purpose: hooks are materialized file by file under `.rsc/`, so this imports nothing
33
36
  // but Node. Never throws into a hook; whatever goes wrong is said on the next message.
@@ -36,7 +39,7 @@ import {
36
39
  closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
37
40
  unlinkSync, writeFileSync,
38
41
  } from 'node:fs';
39
- import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
42
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
40
43
  import { fileURLToPath } from 'node:url';
41
44
  import { defaultBranchName, trunkClosed } from './trunk-policy.mjs';
42
45
 
@@ -48,17 +51,33 @@ export const OPT_OUT = '.no-knowledge-sync';
48
51
  export const SKIP_CI = '[skip ci]';
49
52
  /** The exchange branch on the remote. Fixed, in every project (spec, clarify P7). */
50
53
  export const KNOWLEDGE_BRANCH = 'rsc/knowledge';
54
+ /** The subject of what `applyIncoming` commits: content that came FROM the exchange branch. */
55
+ const SYNC_SUBJECT = `📥 docs(auto): sync desde ${KNOWLEDGE_BRANCH}`;
51
56
 
52
57
  const STATE = 'knowledge-sync.json';
53
58
  const LOCK = 'knowledge-sync.lock';
54
59
  const FETCH_EVERY_MS = 60_000;
60
+ /** Older than this, the next message fetches in the foreground first (D4, team sim 2026-10-07). */
61
+ // 3 min, not 10: at 10 a teammate's merge three minutes old was missed and a duplicate 📥 commit of a
62
+ // doc main already had went into a feature branch (second team simulation). Offline cost: ≤3 s per 3 min.
63
+ const STALE_FETCH_MS = 3 * 60_000;
64
+ /** …but never waits longer than this for it: fail-open, the background fetch still follows. */
65
+ const FOREGROUND_FETCH_MS = 3_000;
55
66
  const NET_TIMEOUT_MS = 30_000;
56
67
  const LOCAL_TIMEOUT_MS = 4_000;
57
68
  const LOCK_STALE_MS = 120_000;
58
69
  const MAX_FILES = 200;
59
70
  const KEEP_OURS = 200;
60
71
 
61
- export const isKnowledge = (path) => KNOWLEDGE.some((k) => path.startsWith(k)) && !PERSONAL.includes(path);
72
+ /**
73
+ * rsc's own scaffolding, identical in every project: it arrives with rsc, not with a teammate. Sent up,
74
+ * it turned the first turn of a fresh install into a "docs" commit of CREDENTIALS.md and
75
+ * test_connection.sh on the working branch (E2E 2026-10-07). A team that customises the template
76
+ * still commits it like any file; it just does not travel on its own.
77
+ */
78
+ export const SCAFFOLD = Object.freeze(['01-TOOLS/_TEMPLATE/']);
79
+ export const isKnowledge = (path) => KNOWLEDGE.some((k) => path.startsWith(k)) && !PERSONAL.includes(path)
80
+ && !SCAFFOLD.some((k) => path.startsWith(k));
62
81
 
63
82
  // ------------------------------------------------------------------ git
64
83
 
@@ -105,7 +124,7 @@ const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root,
105
124
  function readState(root) {
106
125
  let s = {};
107
126
  try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
108
- return { announced: false, seenBy: {}, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, head: null, ...s };
127
+ return { announced: false, seenBy: {}, waiting: {}, queue: [], ours: [], notices: [], clashes: [], lastFetch: 0, snap: null, head: null, ...s };
109
128
  }
110
129
 
111
130
  function writeState(root, s) {
@@ -147,6 +166,26 @@ function listed(paths, max = 5) {
147
166
  return paths.length > max ? `${shown} y ${paths.length - max} más` : shown;
148
167
  }
149
168
 
169
+ /**
170
+ * Who each file is from: the author of the newest commit that changed it, and «ti» when that commit is
171
+ * one of ours or carries your own git email. One name for the whole list told Bruno his own doc was
172
+ * Ana's (second team simulation).
173
+ */
174
+ function byAuthor(root, files, touchedBy, ours) {
175
+ const me = line(root, ['config', 'user.email']) || '';
176
+ const groups = new Map();
177
+ for (const f of files) {
178
+ const last = touchedBy(f)[0]; // rev-list lists newest first
179
+ let who = 'ti, desde otra rama';
180
+ if (last && !ours.includes(last)) {
181
+ const [name, email] = (line(root, ['log', '-1', '--format=%an%x09%ae', last]) || '').split('\t');
182
+ if (name && (!me || email !== me)) who = clean(name, 30);
183
+ }
184
+ groups.set(who, [...(groups.get(who) || []), f]);
185
+ }
186
+ return [...groups].map(([who, list]) => `de ${who}: ${listed(list)}`).join('; ');
187
+ }
188
+
150
189
  const authorsOf = (root, revs) => [...new Set(revs.flatMap((c) => {
151
190
  const a = line(root, ['log', '-1', '--format=%an', c]);
152
191
  return a ? [clean(a, 30)] : [];
@@ -185,10 +224,17 @@ function changedKnowledge(root) {
185
224
  return [...new Set(files)].filter(isKnowledge);
186
225
  }
187
226
 
188
- function summary(files) {
189
- const names = [...new Set(files.map((f) => basename(f, extname(f))))].sort();
190
- return listed(names, 3);
227
+ // The subject of an auto-commit. Counted by FILE and named by full path: it used to dedupe by
228
+ // basename-without-extension, so eight files read as "… y 4 más" and `01-TOOLS/.gitignore` was
229
+ // indistinguishable from `01-TOOLS/_TEMPLATE/.gitignore`.
230
+ export function commitSummary(files) {
231
+ const paths = [...new Set(files)].sort();
232
+ if (paths.length <= 1) return paths.map((p) => clean(p, 80)).join('');
233
+ const max = 3;
234
+ const shown = paths.slice(0, max).map((p) => clean(p, 80)).join(', ');
235
+ return `${paths.length} ficheros: ${shown}${paths.length > max ? ` y ${paths.length - max} más` : ''}`;
191
236
  }
237
+ const summary = commitSummary;
192
238
 
193
239
  /**
194
240
  * Commit your knowledge changes so they can go up. Local and fast; returns the new SHA or null.
@@ -198,13 +244,37 @@ function summary(files) {
198
244
  * goes up like any other; the files stay modified until the agent commits on a branch (regla A).
199
245
  * Repeating it while they stay modified is a no-op upstream — the content is already there.
200
246
  */
247
+ /**
248
+ * Work of the agent's own still uncommitted outside the knowledge folders. While there is, a docs
249
+ * commit of ours on the branch would land AHEAD of the code it describes — field test 3.0.8: an FTD
250
+ * update saying «Fix … probado» committed and pushed while the fix itself was still uncommitted. The
251
+ * harness's own files (`.claude/`, `.rsc.json`) do not count: they may stay untracked for good.
252
+ */
253
+ function workPending(root) {
254
+ const entries = zlist(git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']));
255
+ for (let i = 0; i < entries.length; i++) {
256
+ const e = entries[i];
257
+ const path = e.slice(3);
258
+ if (e[0] === 'R' || e[0] === 'C') i++;
259
+ if (KNOWLEDGE.some((k) => path.startsWith(k)) || PERSONAL.includes(path)) continue;
260
+ if (/^(\.claude\/|\.rsc\/|\.rsc\.json$|\.worktrees\/)/.test(path)) continue;
261
+ return true;
262
+ }
263
+ return false;
264
+ }
265
+
201
266
  export function commitLocal(root, s) {
202
267
  const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
203
268
  if (branch === 'HEAD') return null; // detached
204
269
  const files = changedKnowledge(root).slice(0, MAX_FILES);
205
270
  if (!files.length) return null;
206
- const message = (names) => `📝 docs(auto): ${summary(names)} ${SKIP_CI}`;
207
- if (branch === defaultBranch(root) && trunkClosed(root)) {
271
+ // No [skip ci] HERE: this commit lands on the working branch, often as its newest commit, and the
272
+ // next ordinary push of that branch would skip CI on main (E2E 2026-10-07). Only the copy on the
273
+ // exchange branch carries it (see `ship`).
274
+ const message = (names) => `📝 docs(auto): ${summary(names)}`;
275
+ // Closed trunk, or code of the agent's still uncommitted: no commit of ours on the branch. The
276
+ // snapshot goes up the same way and the files stay modified, to be committed WITH that work.
277
+ if ((branch === defaultBranch(root) && trunkClosed(root)) || workPending(root)) {
208
278
  const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.snap.index') };
209
279
  try {
210
280
  if (!run(root, ['read-tree', 'HEAD'], { env }).ok) return null;
@@ -249,8 +319,22 @@ export function queueCommitted(root, s) {
249
319
  && run(root, ['merge-base', '--is-ancestor', s.head, head]).ok;
250
320
  const from = valid ? s.head : (remoteDef && line(root, ['merge-base', 'HEAD', remoteDef]));
251
321
  if (!head || !from || from === head) return;
252
- const commits = git(root, ['rev-list', '--reverse', '--no-merges', `${from}..${head}`, '--', ...KNOWLEDGE]).split('\n').filter(Boolean);
253
- for (const c of commits) if (!s.queue.includes(c) && !s.ours.includes(c)) s.queue.push(c);
322
+ // Only what was made HERE (team sim 2026-10-07, D3). `git merge origin/main` on a feature branch
323
+ // brings teammates' commits into `from..head`; walked whole, they were re-sent as ours, clashed with
324
+ // their own newer version on rsc/knowledge, and the person was told «No he podido subir …» about a
325
+ // file they never wrote. So: the branch's own line (`--first-parent`: a merge's second side is not
326
+ // ours), nothing the remote default branch or the exchange branch already has (a rebase onto main
327
+ // puts teammates' commits on the first-parent line too), and never a 📥 sync commit, whose content
328
+ // came FROM rsc/knowledge. Not excluded: other remote branches — your own pushed feature branch
329
+ // holds exactly the commits that must still go up.
330
+ const not = [def && `refs/remotes/origin/${def}`, `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]
331
+ .filter((r) => line(root, ['rev-parse', '--verify', '--quiet', r])).map((r) => `^${r}`);
332
+ const commits = git(root, ['rev-list', '--reverse', '--no-merges', '--first-parent', `${from}..${head}`, ...not, '--', ...KNOWLEDGE]).split('\n').filter(Boolean);
333
+ for (const c of commits) {
334
+ if (s.queue.includes(c) || s.ours.includes(c)) continue;
335
+ if (line(root, ['log', '-1', '--format=%s', c]) === SYNC_SUBJECT) continue;
336
+ s.queue.push(c);
337
+ }
254
338
  }
255
339
 
256
340
  // ------------------------------------------------------------------ network: up
@@ -299,6 +383,30 @@ function replay(root, base, sha, ours = []) {
299
383
  }
300
384
  }
301
385
 
386
+ const clashText = (files) => `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
387
+ 'Tu versión sigue aquí; hay que juntarlas a mano.';
388
+
389
+ /**
390
+ * The notices, with every clash checked again first (D3). A clash is noted when the push runs and said
391
+ * on a later message; in between the person may have taken the other version, or the two may have
392
+ * converged. A file whose version here — in the working tree, or in the commit that clashed — is now
393
+ * the one on rsc/knowledge is no clash any more, and a notice left with no files is not said.
394
+ */
395
+ function openNotices(root, s) {
396
+ const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
397
+ const out = [];
398
+ for (const text of s.notices) {
399
+ const c = (s.clashes || []).find((x) => x.text === text);
400
+ if (!c || !tip) { out.push(text); continue; }
401
+ const open = c.files.filter((f) => {
402
+ const there = blob(root, tip, f);
403
+ return worktreeBlob(root, f) !== there && blob(root, c.sha, f) !== there;
404
+ });
405
+ if (open.length) out.push(open.length === c.files.length ? text : clashText(open));
406
+ }
407
+ return out;
408
+ }
409
+
302
410
  /**
303
411
  * Put the queued commits on `rsc/knowledge`. Never the default branch; never the working tree or the
304
412
  * index. Each queued commit is replayed on top of the exchange branch (`replay`), which is how a
@@ -328,8 +436,10 @@ export function ship(root, s) {
328
436
  const tree = replay(root, base, sha, s.ours);
329
437
  if (tree === null) {
330
438
  const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
331
- note(s, `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
332
- 'Tu versión sigue aquí; hay que juntarlas a mano.');
439
+ const text = clashText(files);
440
+ note(s, text);
441
+ // Kept with what it is about, so it can be checked again before it is said (`openClashes`).
442
+ if (!s.clashes.some((c) => c.text === text)) s.clashes.push({ text, sha, files });
333
443
  continue;
334
444
  }
335
445
  if (tree === line(root, ['rev-parse', `${base}^{tree}`])) { kept.push(sha); continue; } // already there
@@ -337,7 +447,9 @@ export function ship(root, s) {
337
447
  // A commit of somebody's own (code + docs) goes up as its knowledge part, under a message that
338
448
  // says so and carries [skip ci]: its own message would describe code that is not there, and
339
449
  // would run CI on the exchange branch.
340
- const body = rest.join('\n').includes(SKIP_CI) ? rest : [
450
+ const ownAuto = /^📝 docs\(auto\):/.test(rest[0] || '');
451
+ const body = ownAuto ? [`${rest[0].replace(SKIP_CI, '').trim()} ${SKIP_CI}`, ...rest.slice(1)]
452
+ : rest.join('\n').includes(SKIP_CI) ? rest : [
341
453
  `📝 docs(auto): ${summary(zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge))} ${SKIP_CI}`,
342
454
  '', `Desde: ${rest[0] || sha.slice(0, 7)}`];
343
455
  base = git(root, ['commit-tree', tree, '-p', base, '-F', '-'], {
@@ -363,8 +475,8 @@ export function ship(root, s) {
363
475
  // ------------------------------------------------------------------ local: their changes
364
476
 
365
477
  /**
366
- * Apply what others put on `rsc/knowledge`, from the last fetch — no network here — into whatever
367
- * branch you are on. Only knowledge paths are looked at: the exchange branch is born from the default
478
+ * Apply what others put on `rsc/knowledge`, from the last fetch, into the branch you are on — except a
479
+ * default branch closed for the agent, which is only told what is waiting (D1, below). Only knowledge paths are looked at: the exchange branch is born from the default
368
480
  * branch, so it also carries code, and code reaches you the ordinary way. Only files you have not
369
481
  * touched since; a clash is said, never forced. Never a fast-forward: that would move your local
370
482
  * default branch onto a commit the remote default branch does not have.
@@ -389,10 +501,44 @@ export function applyIncoming(root, s) {
389
501
  const commits = git(root, ['rev-list', `${seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean);
390
502
  if (!commits.length) { s.seenBy[branch] = tip; return said; }
391
503
 
504
+ const def = defaultBranch(root);
505
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
506
+ // Already on the remote default branch exactly as on rsc/knowledge (D3b): the branch gets it from
507
+ // the ordinary merge of main. Brought in here it would only be a duplicate 📥 commit — and on a
508
+ // default branch that is behind, it would turn the next fast-forward pull into a merge.
509
+ //
510
+ // What this does NOT remove (written down so nobody believes it does): a doc brought into a feature
511
+ // branch while it was only on rsc/knowledge, and later merged into main in a DIFFERENT version
512
+ // through another PR, still meets main's version add/add when that branch merges main. Avoiding it
513
+ // means not bringing docs into branches at all, which is the whole point of the sync; usually the
514
+ // newer version reaches the branch through rsc/knowledge first and the adds are identical (clean).
515
+ const onDefault = (f, incoming) => incoming && remoteDef && blob(root, remoteDef, f) === incoming;
392
516
  const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z', seen, tip, '--', ...paths]))
393
- .filter(isKnowledge).slice(0, MAX_FILES);
394
- const closed = branch === defaultBranch(root) && trunkClosed(root);
517
+ .filter(isKnowledge).filter((f) => !onDefault(f, blob(root, tip, f))).slice(0, MAX_FILES);
518
+ const closed = branch === def && trunkClosed(root);
395
519
  const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
520
+
521
+ // A default branch closed for the agent is left exactly as the remote has it (team sim 2026-10-07,
522
+ // D1). Nothing can be committed there, and anything written uncommitted blocks the person's next
523
+ // `git pull`: these same docs reach main inside merged PRs, and git refuses to merge over an
524
+ // untracked file ("untracked working tree files would be overwritten") AND over a modified tracked
525
+ // one, even when the contents are identical (checked by hand on git 2.4x). Writing only new files,
526
+ // or only tracked ones, still leaves one of the two. So: say what is waiting, once per new tip, and
527
+ // leave `seenBy` where it is — the first message on a branch opened from here catches up and
528
+ // commits it there, where a merge of main meets it as an ordinary change.
529
+ if (closed) {
530
+ s.waiting = s.waiting && typeof s.waiting === 'object' ? s.waiting : {};
531
+ if (s.waiting[branch] === tip) return said;
532
+ s.waiting[branch] = tip;
533
+ const pending = know.filter((f) => worktreeBlob(root, f) !== blob(root, tip, f));
534
+ if (!pending.length) return said;
535
+ said.push(`📥 Hay cambios de conocimiento en ${KNOWLEDGE_BRANCH} (${byAuthor(root, pending, touchedBy, s.ours)}). ` +
536
+ `En ${branch} (cerrada) no los escribo, porque bloquearían tu próximo git pull: llegan con el pull ` +
537
+ 'cuando se fusionen, o a la rama que abras en su primer mensaje. ' +
538
+ `Para leer uno ya: git show origin/${KNOWLEDGE_BRANCH}:<fichero>.`);
539
+ return said;
540
+ }
541
+
396
542
  const take = [];
397
543
  const clash = [];
398
544
  for (const f of know) {
@@ -400,9 +546,7 @@ export function applyIncoming(root, s) {
400
546
  const mine = worktreeBlob(root, f);
401
547
  if (mine === incoming) continue;
402
548
  const base = blob(root, seen, f);
403
- // On a closed trunk HEAD lags behind on purpose (what came in was not committed), so only the
404
- // working tree says whether you touched it (review H2).
405
- if (mine === base && (closed || blob(root, 'HEAD', f) === base)) take.push(f);
549
+ if (mine === base && blob(root, 'HEAD', f) === base) take.push(f);
406
550
  // Touched here and changed only by your own uploads: your newer version, not a clash.
407
551
  else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
408
552
  }
@@ -412,18 +556,8 @@ export function applyIncoming(root, s) {
412
556
  const present = take.filter((f) => blob(root, tip, f));
413
557
  if (present.length) git(root, ['--literal-pathspecs', 'checkout', tip, '--', ...present]);
414
558
  if (gone.length) git(root, ['--literal-pathspecs', 'rm', '--quiet', '--', ...gone]);
415
- // On a default branch closed for the agent nothing is committed there: the files are updated
416
- // and travel into the next branch the agent opens (regla A).
417
- if (!closed) {
418
- git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only',
419
- '-m', `📥 docs(auto): sync desde ${KNOWLEDGE_BRANCH} ${SKIP_CI}`, '--', ...take]);
420
- } else {
421
- git(root, ['--literal-pathspecs', 'reset', '--quiet', '--', ...take]); // updated, not staged
422
- }
423
- const from = [...new Set(take.flatMap(touchedBy))];
424
- const theirs = from.filter((c) => !s.ours.includes(c));
425
- const who = theirs.length ? authorsOf(root, theirs) : 'ti, desde otra rama';
426
- said.push(`📥 Traídos ${from.length} cambio(s) de conocimiento de ${who}: ${listed(take)}.`);
559
+ git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only', '-m', SYNC_SUBJECT, '--', ...take]);
560
+ said.push(`📥 Traídos ${take.length} fichero(s) de conocimiento (${byAuthor(root, take, touchedBy, s.ours)}).`);
427
561
  }
428
562
  if (clash.length) {
429
563
  const who = authorsOf(root, [...new Set(clash.flatMap(touchedBy))].filter((c) => !s.ours.includes(c)));
@@ -455,22 +589,40 @@ function background(root, op) {
455
589
  } catch { /* the next turn tries again */ }
456
590
  }
457
591
 
458
- const ANNOUNCE = '🔄 La sincronización del conocimiento está activa: lo que cambies en 01-TOOLS/ y 02-DOCS/ ' +
459
- `viaja a tu equipo por la rama ${KNOWLEDGE_BRANCH} (nunca a la principal, con [skip ci]) y lo de los demás ` +
460
- 'te llega a la rama en la que estés. Llega a la principal dentro de vuestras PRs. Para apagarla en este ' +
461
- 'proyecto: `rsc knowledge-sync off`.';
592
+ /** Refresh what comes down: the exchange branch, and the default branch it is compared against (D3b). */
593
+ function fetchDown(root, timeout) {
594
+ const def = defaultBranch(root);
595
+ const refspecs = [`+refs/heads/${KNOWLEDGE_BRANCH}:refs/remotes/origin/${KNOWLEDGE_BRANCH}`,
596
+ ...(def ? [`+refs/heads/${def}:refs/remotes/origin/${def}`] : [])];
597
+ // One round trip; absent upstream (no exchange branch yet) fails it, so the default goes alone.
598
+ if (run(root, ['fetch', '--quiet', 'origin', ...refspecs], { timeout }).ok) return true;
599
+ if (def) run(root, ['fetch', '--quiet', 'origin', refspecs[1]], { timeout });
600
+ return false;
601
+ }
602
+
603
+ const ANNOUNCE = '🔄 La sincronización del conocimiento está activa: lo que cambies en 01-TOOLS/, 02-DOCS/wiki/ ' +
604
+ `y 02-DOCS/attachments/ viaja a tu equipo por la rama ${KNOWLEDGE_BRANCH} (nunca a la principal; allí con ` +
605
+ '[skip ci]) y lo de los demás te llega a tu rama de trabajo. Llega a la principal dentro de vuestras PRs. ' +
606
+ 'Para apagarla en este proyecto: `rsc knowledge-sync off`.';
462
607
 
463
608
  /** UserPromptSubmit: apply what was fetched, say what happened, fetch again in the background. */
464
609
  export function onRequest(root, { spawnFetch = true } = {}) {
465
610
  try {
466
611
  if (inactiveReason(root)) return '';
467
612
  let fetchDue = false;
613
+ // The background fetch lands AFTER the message that started it, so what comes down is one message
614
+ // late; a session of one message saw nothing (D4). When the last fetch is old, one bounded fetch
615
+ // first — outside the lock, fail-open. Otherwise no network here, so a turn stays cheap.
616
+ const fresh = Date.now() - readState(root).lastFetch > STALE_FETCH_MS
617
+ && fetchDown(root, FOREGROUND_FETCH_MS);
468
618
  const said = locked(root, (s) => {
469
619
  const out = [];
620
+ if (fresh) s.lastFetch = Date.now();
470
621
  if (!s.announced) { out.push(ANNOUNCE); s.announced = true; }
471
622
  try { out.push(...applyIncoming(root, s)); } catch (e) { out.push(`La sincronización del conocimiento falló al traer cambios: ${clean(e.message, 160)}`); }
472
- out.push(...s.notices);
623
+ try { out.push(...openNotices(root, s)); } catch { out.push(...s.notices); }
473
624
  s.notices = [];
625
+ s.clashes = [];
474
626
  if (Date.now() - s.lastFetch > FETCH_EVERY_MS) { s.lastFetch = Date.now(); fetchDue = true; }
475
627
  return out;
476
628
  });
@@ -530,8 +682,7 @@ export function work(root, op) {
530
682
  if (op === 'ship') ship(root, s);
531
683
  else if (op === 'fetch') {
532
684
  // What comes down comes from the exchange branch; absent upstream is not an error.
533
- run(root, ['fetch', '--quiet', 'origin', `+refs/heads/${KNOWLEDGE_BRANCH}:refs/remotes/origin/${KNOWLEDGE_BRANCH}`],
534
- { timeout: NET_TIMEOUT_MS });
685
+ fetchDown(root, NET_TIMEOUT_MS);
535
686
  if (s.queue.length) ship(root, s);
536
687
  }
537
688
  } catch (e) {
@@ -565,7 +716,14 @@ export function hook(target, event, native = {}) {
565
716
  if (event === 'request') {
566
717
  const said = onRequest(root);
567
718
  if (!said) return {};
568
- return target === 'cursor' ? { user_message: said } : { systemMessage: said };
719
+ if (target === 'cursor') return { user_message: said };
720
+ // The person sees it (`systemMessage`), and now the model knows it too: shown only to the person,
721
+ // a «📥 llegó un cambio» was news the agent could not explain when asked «¿qué cambió?» a turn
722
+ // later (field test 3.0.4). Same text, so what they each know is the same thing.
723
+ const context = { hookEventName: target === 'gemini' ? 'BeforeAgent' : 'UserPromptSubmit', additionalContext: `rsc knowledge-sync (already shown to the person):\n${said}` };
724
+ // DeepSeek Harness drops systemMessage and rsc's bridge turns it into context: one copy is enough.
725
+ if (target === 'deepseek') return { hookSpecificOutput: { ...context, additionalContext: `rsc knowledge-sync — tell the person in one line:\n${said}` } };
726
+ return { systemMessage: said, hookSpecificOutput: context };
569
727
  }
570
728
  // `stop_hook_active`: this turn exists because a stop hook asked for it. Nothing new to commit.
571
729
  if (event === 'turn' && !native?.stop_hook_active) onTurn(root);
@@ -22,6 +22,7 @@ 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-nudge', // once-per-session reminder: code changed and no feature document yet
25
26
  'gitmoji', // the commit-message convention
26
27
  'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
27
28
  '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);