@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.
- package/README.md +22 -11
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/onboarding-apply.js +27 -24
- package/scripts/lib/onboarding.js +19 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +92 -13
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +211 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/worktree-reaper.mjs +109 -3
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Knowledge sync: `01-TOOLS/` and `02-DOCS/`
|
|
3
|
-
//
|
|
4
|
-
// before your next
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
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
|
|
367
|
-
* branch
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/targets/opt-outs.js
CHANGED
|
@@ -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
|
|
341
|
-
|
|
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
|
-
|
|
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);
|