@damiangilgonzalez/harness 1.3.18 → 1.3.19

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.
@@ -0,0 +1,759 @@
1
+ #!/usr/bin/env node
2
+ // Knowledge sync: `01-TOOLS/`, `docs/wiki/` and `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.
6
+ //
7
+ // It never touches the default branch (team-safe-default D: assume it is protected). Knowledge travels
8
+ // between people through one exchange branch on the remote, `rsc/knowledge`: up to it, down from it,
9
+ // into whatever branch each person is on. It reaches the default branch inside everybody's ordinary
10
+ // pull requests — every working branch already carries it, so whichever merges first takes it there,
11
+ // and the next one carries the same content and does not conflict.
12
+ //
13
+ // On by default, and that is a decision with a price, so the price is kept small on purpose:
14
+ // - it only ever touches KNOWLEDGE paths, and never the personal profile;
15
+ // - it never pushes a commit that is not its own — your unpushed code stays unpushed;
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`);
18
+ // - the first turn says it is on and how to turn it off (`rsc knowledge-sync off`, or
19
+ // `.rsc/.no-knowledge-sync`, a PROJECT switch that travels in `.rsc.json`).
20
+ //
21
+ // Wired on its own (`knowledge-wiring.js`), on the same two lifecycle events the session memory uses —
22
+ // prompt and end of turn — but never inside the memory: the memory promises it makes no network
23
+ // request, and this exists to talk to `origin`. `rsc memory off` does not turn this off, and the other
24
+ // way round.
25
+ //
26
+ // Two speeds, because a hook should answer in seconds and a push may not:
27
+ // - what touches YOUR FILES — committing your changes, applying somebody else's — is local, fast,
28
+ // and runs inside the hook, while nobody else is editing;
29
+ // - what touches THE NETWORK — fetch, push — runs detached, and never touches the working tree or
30
+ // the index: the push is replayed on a throwaway index on top of `rsc/knowledge` (`commit-tree`),
31
+ // which is also what lets it go up from whatever branch you are on without carrying that branch.
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`).
34
+ //
35
+ // Standalone on purpose: hooks are materialized file by file under `.rsc/`, so this imports nothing
36
+ // but Node. Never throws into a hook; whatever goes wrong is said on the next message.
37
+ import { execFileSync, spawn } from 'node:child_process';
38
+ import {
39
+ closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
40
+ unlinkSync, writeFileSync,
41
+ } from 'node:fs';
42
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { defaultBranchName, trunkClosed } from './trunk-policy.mjs';
45
+
46
+ /** What syncs on its own. Everything else is ordinary git, decided by a person. */
47
+ export const KNOWLEDGE = Object.freeze(['01-TOOLS/', 'docs/wiki/', 'docs/attachments/']);
48
+ /** One person's dials (`init`, `orient`): never anybody else's business. */
49
+ export const PERSONAL = Object.freeze(['docs/wiki/harness/user-profile.md']);
50
+ export const OPT_OUT = '.no-knowledge-sync';
51
+ export const SKIP_CI = '[skip ci]';
52
+ /** The exchange branch on the remote. Fixed, in every project (spec, clarify P7). */
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}`;
56
+
57
+ const STATE = 'knowledge-sync.json';
58
+ const LOCK = 'knowledge-sync.lock';
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;
66
+ const NET_TIMEOUT_MS = 30_000;
67
+ const LOCAL_TIMEOUT_MS = 4_000;
68
+ const LOCK_STALE_MS = 120_000;
69
+ const MAX_FILES = 200;
70
+ const KEEP_OURS = 200;
71
+
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));
81
+
82
+ // ------------------------------------------------------------------ git
83
+
84
+ function run(root, args, { timeout = LOCAL_TIMEOUT_MS, input, env } = {}) {
85
+ try {
86
+ const out = execFileSync('git', args, {
87
+ windowsHide: true,
88
+ cwd: root, encoding: 'utf8', timeout, input, maxBuffer: 16 * 1024 * 1024,
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', ...env },
91
+ });
92
+ return { ok: true, status: 0, out, err: '' };
93
+ } catch (e) {
94
+ return { ok: false, status: e.status ?? -1, out: String(e.stdout || ''), err: String(e.stderr || e.message || '') };
95
+ }
96
+ }
97
+
98
+ function git(root, args, opts) {
99
+ const r = run(root, args, opts);
100
+ if (!r.ok) throw new Error(`git ${args.find((a) => !a.startsWith('-'))}: ${r.err.trim().split('\n')[0]}`);
101
+ return r.out;
102
+ }
103
+
104
+ const line = (root, args, opts) => { const r = run(root, args, opts); return r.ok ? r.out.trim() : null; };
105
+ const zlist = (out) => out.split('\0').filter(Boolean);
106
+
107
+ // One answer to "which is the default branch", shared with the guard: a copy without the local
108
+ // main/master fallback once let this module commit on a closed main the remote had not seen yet.
109
+ const defaultBranch = (root) => defaultBranchName(root);
110
+
111
+ function busy(root) {
112
+ for (const p of ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
113
+ const path = line(root, ['rev-parse', '--git-path', p]);
114
+ if (path && existsSync(isAbsolute(path) ? path : join(root, path))) return true; // absolute in a linked worktree
115
+ }
116
+ return false;
117
+ }
118
+
119
+ const blob = (root, rev, path) => line(root, ['rev-parse', '--verify', '--quiet', `${rev}:${path}`]);
120
+ const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root, ['hash-object', '--', path]) : null);
121
+
122
+ // ------------------------------------------------------------------ state
123
+
124
+ function readState(root) {
125
+ let s = {};
126
+ try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
127
+ return { announced: false, seenBy: {}, waiting: {}, queue: [], ours: [], notices: [], clashes: [], lastFetch: 0, snap: null, head: null, ...s };
128
+ }
129
+
130
+ function writeState(root, s) {
131
+ const dir = join(root, '.rsc');
132
+ mkdirSync(dir, { recursive: true });
133
+ const tmp = join(dir, `${STATE}.tmp`);
134
+ s.ours = s.ours.slice(-KEEP_OURS);
135
+ writeFileSync(tmp, JSON.stringify(s, null, 2));
136
+ renameSync(tmp, join(dir, STATE));
137
+ }
138
+
139
+ function note(s, msg) { if (!s.notices.includes(msg)) s.notices.push(msg); }
140
+
141
+ function acquire(root) {
142
+ const path = join(root, '.rsc', LOCK);
143
+ mkdirSync(join(root, '.rsc'), { recursive: true });
144
+ try { if (Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS) unlinkSync(path); } catch { /* none */ }
145
+ try { closeSync(openSync(path, 'wx')); return true; } catch { return false; }
146
+ }
147
+
148
+ function release(root) { try { unlinkSync(join(root, '.rsc', LOCK)); } catch { /* gone */ } }
149
+
150
+ /** The lock and the state live together: a body that runs without the lock never runs. */
151
+ function locked(root, body) {
152
+ if (!acquire(root)) return null;
153
+ const s = readState(root);
154
+ try { return body(s); } finally { writeState(root, s); release(root); }
155
+ }
156
+
157
+ // ------------------------------------------------------------------ text
158
+
159
+ const clean = (text, limit = 60) => {
160
+ const t = String(text).replace(/\s+/g, ' ').trim();
161
+ return t.length <= limit ? t : `${t.slice(0, limit - 1)}…`;
162
+ };
163
+
164
+ function listed(paths, max = 5) {
165
+ const shown = paths.slice(0, max).map((p) => clean(p)).join(', ');
166
+ return paths.length > max ? `${shown} y ${paths.length - max} más` : shown;
167
+ }
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
+
189
+ const authorsOf = (root, revs) => [...new Set(revs.flatMap((c) => {
190
+ const a = line(root, ['log', '-1', '--format=%an', c]);
191
+ return a ? [clean(a, 30)] : [];
192
+ }))].join(', ') || 'alguien';
193
+
194
+ // ------------------------------------------------------------------ enabled
195
+
196
+ /**
197
+ * Why sync is not running here, or null when it is. A reason and not a boolean, so `status` can say
198
+ * which of the quiet conditions applies instead of making the person guess.
199
+ */
200
+ export function inactiveReason(root) {
201
+ if (!existsSync(join(root, '.rsc.json'))) return 'no-harness';
202
+ if (existsSync(join(root, '.rsc', OPT_OUT))) return 'opted-out';
203
+ const top = line(root, ['rev-parse', '--show-toplevel']);
204
+ if (!top) return 'not-a-repo';
205
+ try { if (realpathSync(top) !== realpathSync(root)) return 'harness-not-at-repo-root'; } catch { return 'not-a-repo'; }
206
+ if (!line(root, ['remote', 'get-url', 'origin'])) return 'no-origin';
207
+ if (!KNOWLEDGE.some((k) => existsSync(join(root, k)))) return 'no-knowledge-folders';
208
+ return null;
209
+ }
210
+
211
+ // ------------------------------------------------------------------ local: your changes
212
+
213
+ function changedKnowledge(root) {
214
+ const present = KNOWLEDGE.filter((k) => existsSync(join(root, k)) || line(root, ['ls-files', '--', k]));
215
+ if (!present.length) return [];
216
+ const out = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...present]);
217
+ const entries = zlist(out);
218
+ const files = [];
219
+ for (let i = 0; i < entries.length; i++) {
220
+ const e = entries[i];
221
+ files.push(e.slice(3));
222
+ if (e[0] === 'R' || e[0] === 'C') files.push(entries[++i]); // the old name of a rename follows
223
+ }
224
+ return [...new Set(files)].filter(isKnowledge);
225
+ }
226
+
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` : ''}`;
236
+ }
237
+ const summary = commitSummary;
238
+
239
+ /**
240
+ * Commit your knowledge changes so they can go up. Local and fast; returns the new SHA or null.
241
+ *
242
+ * On a default branch that is closed for the agent (trunk-policy), nothing is committed there: the
243
+ * commit is built on a throwaway index from HEAD plus the knowledge files and left on no branch. It
244
+ * goes up like any other; the files stay modified until the agent commits on a branch (regla A).
245
+ * Repeating it while they stay modified is a no-op upstream — the content is already there.
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: a feature
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
+
266
+ export function commitLocal(root, s) {
267
+ const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
268
+ if (branch === 'HEAD') return null; // detached
269
+ const files = changedKnowledge(root).slice(0, MAX_FILES);
270
+ if (!files.length) return null;
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)) {
278
+ const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.snap.index') };
279
+ try {
280
+ if (!run(root, ['read-tree', 'HEAD'], { env }).ok) return null;
281
+ git(root, ['--literal-pathspecs', 'add', '-A', '--', ...files], { env });
282
+ const tree = line(root, ['write-tree'], { env });
283
+ // Chained to the previous snapshot while HEAD has not moved, so each one carries only what
284
+ // changed since: the files stay modified on a closed trunk, and a snapshot against HEAD would
285
+ // re-offer the first version every turn and read the second as a clash (review H1).
286
+ const head = line(root, ['rev-parse', 'HEAD']);
287
+ const prev = s.snap && s.snap.head === head && line(root, ['rev-parse', '--verify', '--quiet', `${s.snap.commit}^{commit}`]) ? s.snap.commit : head;
288
+ if (!tree || tree === line(root, ['rev-parse', `${prev}^{tree}`])) return null;
289
+ const sha = git(root, ['commit-tree', tree, '-p', prev, '-F', '-'], { input: message(files) }).trim();
290
+ s.snap = { head, commit: sha };
291
+ s.queue.push(sha);
292
+ return sha;
293
+ } finally {
294
+ try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
295
+ }
296
+ }
297
+ git(root, ['--literal-pathspecs', 'add', '-A', '--', ...files]);
298
+ const staged = zlist(git(root, ['--literal-pathspecs', 'diff', '--cached', '--name-only', '-z', '--', ...files]));
299
+ if (!staged.length) return null;
300
+ git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only', '-m', message(staged), '--', ...staged]);
301
+ const sha = line(root, ['rev-parse', 'HEAD']);
302
+ s.queue.push(sha);
303
+ return sha;
304
+ }
305
+
306
+ /**
307
+ * Knowledge the agent (or the person) committed on the branch since the last turn goes up too. Without
308
+ * this only what was still uncommitted when the turn ended travelled, and an agent that commits its
309
+ * feature document with its code in one long turn left nothing to send (E2E 2026-10-04). The first
310
+ * time, and after a branch switch, it starts where the branch left the remote default branch.
311
+ */
312
+ export function queueCommitted(root, s) {
313
+ const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
314
+ if (!branch || branch === 'HEAD') return;
315
+ const head = line(root, ['rev-parse', 'HEAD']);
316
+ const def = defaultBranch(root);
317
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
318
+ const valid = s.head && line(root, ['rev-parse', '--verify', '--quiet', `${s.head}^{commit}`])
319
+ && run(root, ['merge-base', '--is-ancestor', s.head, head]).ok;
320
+ const from = valid ? s.head : (remoteDef && line(root, ['merge-base', 'HEAD', remoteDef]));
321
+ if (!head || !from || from === head) return;
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
+ }
338
+ }
339
+
340
+ // ------------------------------------------------------------------ network: up
341
+
342
+ /**
343
+ * The tree of `base` with the change of commit `sha` applied, or null if it does not apply cleanly.
344
+ * A throwaway index under `.rsc/` and `git apply --cached`: the real index and the working tree are
345
+ * never read or written, and it works on any git (`merge-tree --merge-base` needs 2.40).
346
+ */
347
+ function replay(root, base, sha, ours = []) {
348
+ if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^`])) return null;
349
+ // Only the knowledge paths: a commit of the agent's own on a branch also carries code, and code never
350
+ // travels this way.
351
+ const patch = run(root, ['diff-tree', '-p', '--binary', '--full-index', `${sha}^`, sha, '--', ...KNOWLEDGE], { timeout: NET_TIMEOUT_MS });
352
+ if (!patch.ok) return null;
353
+ if (!patch.out.trim()) return line(root, ['rev-parse', `${base}^{tree}`]);
354
+ const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.index') };
355
+ try {
356
+ if (!run(root, ['read-tree', base], { env }).ok) return null;
357
+ // File by file: one that is already there as it should be is skipped, not read as a clash (a
358
+ // teammate's change taken uncommitted on a closed trunk comes back up in the next snapshot), and
359
+ // one real clash does not hold back the rest of the patch's reasoning.
360
+ const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', `${sha}^`, sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
361
+ for (const f of files) {
362
+ const next = blob(root, sha, f);
363
+ const there = blob(root, base, f);
364
+ if (next === there) continue;
365
+ // What is there is what this change started from, or what we ourselves put there last: ours
366
+ // to replace whole. (A snapshot from a closed trunk can start from HEAD, behind our own last
367
+ // upload — after an upgrade, or once the chain is lost.)
368
+ const last = line(root, ['log', '-1', '--format=%H', base, '--', f]);
369
+ const from = blob(root, `${sha}^`, f);
370
+ if (there === from || (last && ours.includes(last)) || behindDefault(root, base, f, there, from, last)) {
371
+ const entry = next && line(root, ['ls-tree', sha, '--', f]);
372
+ const ok = next
373
+ ? run(root, ['update-index', '--add', '--cacheinfo', `${entry.split(' ')[0]},${next},${f}`], { env }).ok
374
+ : run(root, ['update-index', '--force-remove', '--', f], { env }).ok;
375
+ if (!ok) return null;
376
+ continue;
377
+ }
378
+ const one = run(root, ['diff-tree', '-p', '--binary', '--full-index', '--no-renames', `${sha}^`, sha, '--', f], { timeout: NET_TIMEOUT_MS });
379
+ if (!one.ok || !run(root, ['apply', '--cached', '-'], { env, input: one.out, timeout: NET_TIMEOUT_MS }).ok) return null;
380
+ }
381
+ return line(root, ['write-tree'], { env });
382
+ } finally {
383
+ try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * The exchange branch holds this file only in a version the remote default branch has already gone
389
+ * past — or never held it — and the change starts from the default branch's version: replacing it
390
+ * whole is a fast-forward of the file, not a clash. rsc/knowledge is born from main and never merges
391
+ * it back, so a document merged through a pull request it never carried, then edited again, met it
392
+ * as a clash. A file the exchange branch once had and then lost is not this
393
+ * case: somebody deleted it there, and that is not ours to undo.
394
+ */
395
+ function behindDefault(root, base, f, there, from, last) {
396
+ const def = defaultBranch(root);
397
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
398
+ if (!remoteDef || !from || blob(root, remoteDef, f) !== from) return false;
399
+ if (!there) return !last;
400
+ const revs = (line(root, ['rev-list', '-n', '50', remoteDef, '--', f]) || '').split('\n').filter(Boolean);
401
+ return revs.some((c) => blob(root, c, f) === there);
402
+ }
403
+
404
+ const clashText = (files) => `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
405
+ 'Tu versión sigue aquí; hay que juntarlas a mano.';
406
+
407
+ /**
408
+ * The notices, with every clash checked again first (D3). A clash is noted when the push runs and said
409
+ * on a later message; in between the person may have taken the other version, or the two may have
410
+ * converged. A file whose version here — in the working tree, or in the commit that clashed — is now
411
+ * the one on rsc/knowledge is no clash any more, and a notice left with no files is not said.
412
+ */
413
+ function openNotices(root, s) {
414
+ const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
415
+ const out = [];
416
+ for (const text of s.notices) {
417
+ const c = (s.clashes || []).find((x) => x.text === text);
418
+ if (!c || !tip) { out.push(text); continue; }
419
+ const open = c.files.filter((f) => {
420
+ const there = blob(root, tip, f);
421
+ return worktreeBlob(root, f) !== there && blob(root, c.sha, f) !== there;
422
+ });
423
+ if (open.length) out.push(open.length === c.files.length ? text : clashText(open));
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /**
429
+ * Put the queued commits on `rsc/knowledge`. Never the default branch; never the working tree or the
430
+ * index. Each queued commit is replayed on top of the exchange branch (`replay`), which is how a
431
+ * commit made on `feat/x` goes up without `feat/x`, and how your unpushed code never leaves the
432
+ * machine. The exchange branch is born from the remote default branch the first time, and reborn the
433
+ * same way if somebody deletes it.
434
+ */
435
+ export function ship(root, s) {
436
+ const def = defaultBranch(root);
437
+ if (!def) return;
438
+ const fetched = run(root, ['fetch', '--quiet', 'origin', def], { timeout: NET_TIMEOUT_MS });
439
+ if (!fetched.ok) return; // offline: the queue goes up on a later turn
440
+ if (!s.queue.length) return;
441
+ const ref = `refs/remotes/origin/${KNOWLEDGE_BRANCH}`;
442
+
443
+ for (let attempt = 0; attempt < 2; attempt++) {
444
+ const k = run(root, ['fetch', '--quiet', 'origin', `+refs/heads/${KNOWLEDGE_BRANCH}:${ref}`], { timeout: NET_TIMEOUT_MS });
445
+ if (!k.ok && !/couldn't find remote ref/i.test(k.err)) return;
446
+ if (!k.ok) run(root, ['update-ref', '-d', ref]); // deleted upstream: reborn from the default branch
447
+ const tip = line(root, ['rev-parse', '--verify', '--quiet', ref]) || line(root, ['rev-parse', `refs/remotes/origin/${def}`]);
448
+ if (!tip) return;
449
+ let base = tip;
450
+ const built = [];
451
+ const kept = [];
452
+ for (const sha of s.queue) {
453
+ if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`])) continue; // rewritten away
454
+ const tree = replay(root, base, sha, s.ours);
455
+ if (tree === null) {
456
+ const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
457
+ const text = clashText(files);
458
+ note(s, text);
459
+ // Kept with what it is about, so it can be checked again before it is said (`openClashes`).
460
+ if (!s.clashes.some((c) => c.text === text)) s.clashes.push({ text, sha, files });
461
+ continue;
462
+ }
463
+ if (tree === line(root, ['rev-parse', `${base}^{tree}`])) { kept.push(sha); continue; } // already there
464
+ const [name, email, date, ...rest] = git(root, ['log', '-1', '--format=%an%n%ae%n%aI%n%B', sha]).split('\n');
465
+ // A commit of somebody's own (code + docs) goes up as its knowledge part, under a message that
466
+ // says so and carries [skip ci]: its own message would describe code that is not there, and
467
+ // would run CI on the exchange branch.
468
+ const ownAuto = /^📝 docs\(auto\):/.test(rest[0] || '');
469
+ const body = ownAuto ? [`${rest[0].replace(SKIP_CI, '').trim()} ${SKIP_CI}`, ...rest.slice(1)]
470
+ : rest.join('\n').includes(SKIP_CI) ? rest : [
471
+ `📝 docs(auto): ${summary(zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge))} ${SKIP_CI}`,
472
+ '', `Desde: ${rest[0] || sha.slice(0, 7)}`];
473
+ base = git(root, ['commit-tree', tree, '-p', base, '-F', '-'], {
474
+ input: body.join('\n'),
475
+ env: { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date },
476
+ }).trim();
477
+ built.push(base);
478
+ }
479
+ if (base === tip && line(root, ['rev-parse', '--verify', '--quiet', ref])) { s.ours.push(...kept); s.queue = []; return; }
480
+ const r = run(root, ['push', '--quiet', 'origin', `${base}:refs/heads/${KNOWLEDGE_BRANCH}`], { timeout: NET_TIMEOUT_MS });
481
+ if (r.ok) {
482
+ run(root, ['update-ref', ref, base]);
483
+ s.ours.push(...built, ...kept);
484
+ s.queue = [];
485
+ return;
486
+ }
487
+ // Somebody pushed to the exchange branch in between: look again once, then say so.
488
+ }
489
+ note(s, `No he podido subir los cambios de conocimiento a ${KNOWLEDGE_BRANCH}. ` +
490
+ 'Siguen comiteados aquí; se reintenta en el próximo turno.');
491
+ }
492
+
493
+ // ------------------------------------------------------------------ local: their changes
494
+
495
+ /**
496
+ * Apply what others put on `rsc/knowledge`, from the last fetch, into the branch you are on — except a
497
+ * 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
498
+ * branch, so it also carries code, and code reaches you the ordinary way. Only files you have not
499
+ * touched since; a clash is said, never forced. Never a fast-forward: that would move your local
500
+ * default branch onto a commit the remote default branch does not have.
501
+ */
502
+ export function applyIncoming(root, s) {
503
+ const said = [];
504
+ if (busy(root)) return said;
505
+ const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
506
+ if (!branch || branch === 'HEAD') return said;
507
+ const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
508
+ if (!tip) return said;
509
+ // Per branch, not per repo: a branch opened from main after the last sync starts behind what
510
+ // rsc/knowledge already has, and a single "last seen" made it look up to date (2026-10-06). A branch
511
+ // seen for the first time catches up from where it left rsc/knowledge.
512
+ s.seenBy = s.seenBy && typeof s.seenBy === 'object' ? s.seenBy : {};
513
+ let seen = s.seenBy[branch];
514
+ if (!seen || !line(root, ['rev-parse', '--verify', '--quiet', `${seen}^{commit}`])) {
515
+ seen = line(root, ['merge-base', 'HEAD', tip]) || tip;
516
+ }
517
+ if (seen === tip) { s.seenBy[branch] = tip; return said; }
518
+ const paths = [...KNOWLEDGE];
519
+ const commits = git(root, ['rev-list', `${seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean);
520
+ if (!commits.length) { s.seenBy[branch] = tip; return said; }
521
+
522
+ const def = defaultBranch(root);
523
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
524
+ // Already on the remote default branch exactly as on rsc/knowledge (D3b): the branch gets it from
525
+ // the ordinary merge of main. Brought in here it would only be a duplicate 📥 commit — and on a
526
+ // default branch that is behind, it would turn the next fast-forward pull into a merge.
527
+ //
528
+ // What this does NOT remove (written down so nobody believes it does): a doc brought into a feature
529
+ // branch while it was only on rsc/knowledge, and later merged into main in a DIFFERENT version
530
+ // through another PR, still meets main's version add/add when that branch merges main. Avoiding it
531
+ // means not bringing docs into branches at all, which is the whole point of the sync; usually the
532
+ // newer version reaches the branch through rsc/knowledge first and the adds are identical (clean).
533
+ const onDefault = (f, incoming) => incoming && remoteDef && blob(root, remoteDef, f) === incoming;
534
+ const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z', seen, tip, '--', ...paths]))
535
+ .filter(isKnowledge).filter((f) => !onDefault(f, blob(root, tip, f))).slice(0, MAX_FILES);
536
+ const closed = branch === def && trunkClosed(root);
537
+ const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
538
+ const superseded = (f, mine) => touchedBy(f).some((c) => blob(root, c, f) === mine);
539
+
540
+ // A default branch closed for the agent is left exactly as the remote has it (team sim 2026-10-07,
541
+ // D1). Nothing can be committed there, and anything written uncommitted blocks the person's next
542
+ // `git pull`: these same docs reach main inside merged PRs, and git refuses to merge over an
543
+ // untracked file ("untracked working tree files would be overwritten") AND over a modified tracked
544
+ // one, even when the contents are identical (checked by hand on git 2.4x). Writing only new files,
545
+ // or only tracked ones, still leaves one of the two. So: say what is waiting, once per new tip, and
546
+ // leave `seenBy` where it is — the first message on a branch opened from here catches up and
547
+ // commits it there, where a merge of main meets it as an ordinary change.
548
+ if (closed) {
549
+ s.waiting = s.waiting && typeof s.waiting === 'object' ? s.waiting : {};
550
+ if (s.waiting[branch] === tip) return said;
551
+ s.waiting[branch] = tip;
552
+ const pending = know.filter((f) => worktreeBlob(root, f) !== blob(root, tip, f));
553
+ if (!pending.length) return said;
554
+ said.push(`📥 Hay cambios de conocimiento en ${KNOWLEDGE_BRANCH} (${byAuthor(root, pending, touchedBy, s.ours)}). ` +
555
+ `En ${branch} (cerrada) no los escribo, porque bloquearían tu próximo git pull: llegan con el pull ` +
556
+ 'cuando se fusionen, o a la rama que abras en su primer mensaje. ' +
557
+ `Para leer uno ya: git show origin/${KNOWLEDGE_BRANCH}:<fichero>.`);
558
+ return said;
559
+ }
560
+
561
+ const take = [];
562
+ const clash = [];
563
+ for (const f of know) {
564
+ const incoming = blob(root, tip, f);
565
+ const mine = worktreeBlob(root, f);
566
+ if (mine === incoming) continue;
567
+ const base = blob(root, seen, f);
568
+ const committed = blob(root, 'HEAD', f);
569
+ if (mine === base && committed === base) take.push(f);
570
+ // Untouched here, and a version the exchange branch itself went past: it passed through it, so the
571
+ // incoming one is its newer version, not somebody else's competing edit (field test 3.0.8).
572
+ else if (mine && mine === committed && superseded(f, mine)) take.push(f);
573
+ // Touched here and changed only by your own uploads: your newer version, not a clash.
574
+ else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
575
+ }
576
+
577
+ if (take.length) {
578
+ const gone = take.filter((f) => !blob(root, tip, f));
579
+ const present = take.filter((f) => blob(root, tip, f));
580
+ if (present.length) git(root, ['--literal-pathspecs', 'checkout', tip, '--', ...present]);
581
+ if (gone.length) git(root, ['--literal-pathspecs', 'rm', '--quiet', '--', ...gone]);
582
+ git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only', '-m', SYNC_SUBJECT, '--', ...take]);
583
+ said.push(`📥 Traídos ${take.length} fichero(s) de conocimiento (${byAuthor(root, take, touchedBy, s.ours)}).`);
584
+ }
585
+ if (clash.length) {
586
+ const who = authorsOf(root, [...new Set(clash.flatMap(touchedBy))].filter((c) => !s.ours.includes(c)));
587
+ said.push(`${who} cambió ${listed(clash)}, que tú también has tocado. No lo traigo solo: tu versión sigue intacta.`);
588
+ }
589
+ s.seenBy[branch] = tip;
590
+ return said;
591
+ }
592
+
593
+ /**
594
+ * Which program runs the detached worker. Not always `process.execPath`: OpenCode loads plugins in its
595
+ * own Bun-based binary, so there `execPath` is `opencode` — which does not run scripts — and the push
596
+ * silently never happened (found end to end, not by the unit tests). Anything that is not Node gets
597
+ * `node` from the PATH, which every assistant here already needs to run the hooks.
598
+ */
599
+ export function workerRuntime(execPath = process.execPath, versions = process.versions) {
600
+ return !versions.bun && /^node(?:\.exe)?$/i.test(execPath.split(/[\\/]/).pop()) ? execPath : 'node';
601
+ }
602
+
603
+ function background(root, op) {
604
+ if (process.env.RSC_KNOWLEDGE_SYNC_FOREGROUND === '1') return work(root, op);
605
+ try {
606
+ const self = fileURLToPath(import.meta.url);
607
+ spawn(workerRuntime(), [self, op, root], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
608
+ } catch { /* the next turn tries again */ }
609
+ }
610
+
611
+ /** Refresh what comes down: the exchange branch, and the default branch it is compared against (D3b). */
612
+ function fetchDown(root, timeout) {
613
+ const def = defaultBranch(root);
614
+ const refspecs = [`+refs/heads/${KNOWLEDGE_BRANCH}:refs/remotes/origin/${KNOWLEDGE_BRANCH}`,
615
+ ...(def ? [`+refs/heads/${def}:refs/remotes/origin/${def}`] : [])];
616
+ // One round trip; absent upstream (no exchange branch yet) fails it, so the default goes alone.
617
+ if (run(root, ['fetch', '--quiet', 'origin', ...refspecs], { timeout }).ok) return true;
618
+ if (def) run(root, ['fetch', '--quiet', 'origin', refspecs[1]], { timeout });
619
+ return false;
620
+ }
621
+
622
+ const ANNOUNCE = '🔄 La sincronización del conocimiento está activa: lo que cambies en 01-TOOLS/, docs/wiki/ ' +
623
+ `y docs/attachments/ viaja a tu equipo por la rama ${KNOWLEDGE_BRANCH} (nunca a la principal; allí con ` +
624
+ '[skip ci]) y lo de los demás te llega a tu rama de trabajo. Llega a la principal dentro de vuestras PRs. ' +
625
+ 'Para apagarla en este proyecto: `rsc knowledge-sync off`.';
626
+
627
+ /** UserPromptSubmit: apply what was fetched, say what happened, fetch again in the background. */
628
+ export function onRequest(root, { spawnFetch = true } = {}) {
629
+ try {
630
+ if (inactiveReason(root)) return '';
631
+ let fetchDue = false;
632
+ // The background fetch lands AFTER the message that started it, so what comes down is one message
633
+ // late; a session of one message saw nothing (D4). When the last fetch is old, one bounded fetch
634
+ // first — outside the lock, fail-open. Otherwise no network here, so a turn stays cheap.
635
+ const fresh = Date.now() - readState(root).lastFetch > STALE_FETCH_MS
636
+ && fetchDown(root, FOREGROUND_FETCH_MS);
637
+ const said = locked(root, (s) => {
638
+ const out = [];
639
+ if (fresh) s.lastFetch = Date.now();
640
+ if (!s.announced) { out.push(ANNOUNCE); s.announced = true; }
641
+ try { out.push(...applyIncoming(root, s)); } catch (e) { out.push(`La sincronización del conocimiento falló al traer cambios: ${clean(e.message, 160)}`); }
642
+ try { out.push(...openNotices(root, s)); } catch { out.push(...s.notices); }
643
+ s.notices = [];
644
+ s.clashes = [];
645
+ if (Date.now() - s.lastFetch > FETCH_EVERY_MS) { s.lastFetch = Date.now(); fetchDue = true; }
646
+ return out;
647
+ });
648
+ if (fetchDue && spawnFetch) background(root, 'fetch'); // after the lock is released, or it would find it taken
649
+ return (said || []).join('\n');
650
+ } catch {
651
+ return '';
652
+ }
653
+ }
654
+
655
+ /** Stop: commit your knowledge changes here, then push them in the background. */
656
+ export function onTurn(root, { spawnShip = true } = {}) {
657
+ try {
658
+ if (inactiveReason(root) || busy(root)) return;
659
+ const pending = locked(root, (s) => {
660
+ try { queueCommitted(root, s); } catch { /* the next turn looks again */ }
661
+ try { commitLocal(root, s); } catch (e) { note(s, `La sincronización del conocimiento no pudo comitear: ${clean(e.message, 160)}`); }
662
+ s.head = line(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) || s.head;
663
+ return s.queue.length;
664
+ });
665
+ if (pending && spawnShip) background(root, 'ship');
666
+ } catch { /* a hook never takes the turn down */ }
667
+ }
668
+
669
+ /** For `rsc knowledge-sync status`. */
670
+ export function knowledgeStatus(root) {
671
+ const reason = inactiveReason(root);
672
+ const s = readState(root);
673
+ return {
674
+ active: !reason,
675
+ reason,
676
+ branch: line(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
677
+ defaultBranch: defaultBranch(root),
678
+ exchangeBranch: KNOWLEDGE_BRANCH,
679
+ queued: s.queue.length,
680
+ pendingNotices: s.notices.length,
681
+ lastFetch: s.lastFetch ? new Date(s.lastFetch).toISOString() : null,
682
+ paths: [...KNOWLEDGE],
683
+ neverSynced: [...PERSONAL],
684
+ };
685
+ }
686
+
687
+ // ------------------------------------------------------------------ detached worker
688
+
689
+ function isMainModule(metaUrl) {
690
+ const invoked = process.argv[1];
691
+ if (!invoked) return false;
692
+ const self = fileURLToPath(metaUrl);
693
+ try { return realpathSync(self) === realpathSync(invoked); } catch { return self === invoked; }
694
+ }
695
+
696
+ /** What the detached process does: `ship` pushes the queue, `fetch` refreshes and retries the queue. */
697
+ export function work(root, op) {
698
+ if (!root || inactiveReason(root)) return;
699
+ locked(root, (s) => {
700
+ try {
701
+ if (op === 'ship') ship(root, s);
702
+ else if (op === 'fetch') {
703
+ // What comes down comes from the exchange branch; absent upstream is not an error.
704
+ fetchDown(root, NET_TIMEOUT_MS);
705
+ if (s.queue.length) ship(root, s);
706
+ }
707
+ } catch (e) {
708
+ note(s, `La sincronización del conocimiento falló: ${clean(e.message, 160)}`);
709
+ }
710
+ });
711
+ }
712
+
713
+ // ------------------------------------------------------------------ the hook
714
+
715
+ /** The project is the nearest ancestor holding `.rsc.json`, as for the session memory. */
716
+ function nearestHarness(dir) {
717
+ for (let current = dir; ; current = dirname(current)) {
718
+ if (existsSync(join(current, '.rsc.json'))) return current;
719
+ if (dirname(current) === current) return dir;
720
+ }
721
+ }
722
+
723
+ const isRemote = (target, native) => (target === 'cursor' && (native?.is_background_agent === true || native?.isBackgroundAgent === true))
724
+ || process.env.RSC_REMOTE_AGENT === '1' || process.env.CURSOR_CLOUD_AGENT === '1' || process.env.CODEX_CLOUD_AGENT === '1';
725
+
726
+ /**
727
+ * One lifecycle event from an assistant. `request` = the person sent a message; `turn` = the agent
728
+ * finished. What is said goes to the PERSON (`systemMessage`, which every event accepts; Cursor's
729
+ * `user_message`), never into the model's context.
730
+ */
731
+ export function hook(target, event, native = {}) {
732
+ try {
733
+ if (isRemote(target, native)) return {};
734
+ const root = nearestHarness(resolve(native.cwd || process.env.RSC_PROJECT_CWD || process.cwd()));
735
+ if (event === 'request') {
736
+ const said = onRequest(root);
737
+ if (!said) return {};
738
+ if (target === 'cursor') return { user_message: said };
739
+ // The person sees it (`systemMessage`), and now the model knows it too: shown only to the person,
740
+ // a «📥 llegó un cambio» was news the agent could not explain when asked «¿qué cambió?» a turn
741
+ // later (field test 3.0.4). Same text, so what they each know is the same thing.
742
+ const context = { hookEventName: target === 'gemini' ? 'BeforeAgent' : 'UserPromptSubmit', additionalContext: `rsc knowledge-sync (already shown to the person):\n${said}` };
743
+ return { systemMessage: said, hookSpecificOutput: context };
744
+ }
745
+ // `stop_hook_active`: this turn exists because a stop hook asked for it. Nothing new to commit.
746
+ if (event === 'turn' && !native?.stop_hook_active) onTurn(root);
747
+ } catch { /* a hook never takes the turn down */ }
748
+ return {};
749
+ }
750
+
751
+ function stdinJson() {
752
+ try { const raw = readFileSync(0, 'utf8'); return raw.trim() ? JSON.parse(raw) : {}; } catch { return {}; }
753
+ }
754
+
755
+ if (isMainModule(import.meta.url)) {
756
+ const [op, a, b] = process.argv.slice(2);
757
+ if (op === 'hook') process.stdout.write(`${JSON.stringify(hook(a, b, stdinJson()))}\n`);
758
+ else work(a, op); // detached: `ship <root>` or `fetch <root>`
759
+ }