@ericrisco/rsc 3.0.7 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -12
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +69 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/onboarding-apply.js +31 -25
- package/scripts/lib/onboarding.js +26 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +109 -14
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- 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 +490 -48
- package/targets/opt-outs.js +2 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +219 -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 +114 -4
|
@@ -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,16 +29,17 @@
|
|
|
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.
|
|
34
37
|
import { execFileSync, spawn } from 'node:child_process';
|
|
35
38
|
import {
|
|
36
|
-
closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
|
|
39
|
+
appendFileSync, 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) {
|
|
@@ -128,10 +147,29 @@ function acquire(root) {
|
|
|
128
147
|
|
|
129
148
|
function release(root) { try { unlinkSync(join(root, '.rsc', LOCK)); } catch { /* gone */ } }
|
|
130
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Commits handed over without the lock (the post-merge close runs inside somebody's `git pull` and
|
|
152
|
+
* must not wait for a push in flight): one SHA per line, taken into the queue by the next locked body.
|
|
153
|
+
* Renamed before it is read, so a line appended meanwhile lands in a new file and is never lost.
|
|
154
|
+
*/
|
|
155
|
+
const PENDING = 'knowledge-sync.pending';
|
|
156
|
+
function absorbPending(root, s) {
|
|
157
|
+
const path = join(root, '.rsc', PENDING);
|
|
158
|
+
const taking = `${path}.taking`;
|
|
159
|
+
try { renameSync(path, taking); } catch { return; }
|
|
160
|
+
try {
|
|
161
|
+
for (const sha of readFileSync(taking, 'utf8').split('\n').map((x) => x.trim()).filter(Boolean)) {
|
|
162
|
+
if (!s.queue.includes(sha)) s.queue.push(sha);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* unreadable: nothing to take */ }
|
|
165
|
+
try { unlinkSync(taking); } catch { /* gone */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
/** The lock and the state live together: a body that runs without the lock never runs. */
|
|
132
169
|
function locked(root, body) {
|
|
133
170
|
if (!acquire(root)) return null;
|
|
134
171
|
const s = readState(root);
|
|
172
|
+
absorbPending(root, s);
|
|
135
173
|
try { return body(s); } finally { writeState(root, s); release(root); }
|
|
136
174
|
}
|
|
137
175
|
|
|
@@ -147,6 +185,26 @@ function listed(paths, max = 5) {
|
|
|
147
185
|
return paths.length > max ? `${shown} y ${paths.length - max} más` : shown;
|
|
148
186
|
}
|
|
149
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Who each file is from: the author of the newest commit that changed it, and «ti» when that commit is
|
|
190
|
+
* one of ours or carries your own git email. One name for the whole list told Bruno his own doc was
|
|
191
|
+
* Ana's (second team simulation).
|
|
192
|
+
*/
|
|
193
|
+
function byAuthor(root, files, touchedBy, ours) {
|
|
194
|
+
const me = line(root, ['config', 'user.email']) || '';
|
|
195
|
+
const groups = new Map();
|
|
196
|
+
for (const f of files) {
|
|
197
|
+
const last = touchedBy(f)[0]; // rev-list lists newest first
|
|
198
|
+
let who = 'ti, desde otra rama';
|
|
199
|
+
if (last && !ours.includes(last)) {
|
|
200
|
+
const [name, email] = (line(root, ['log', '-1', '--format=%an%x09%ae', last]) || '').split('\t');
|
|
201
|
+
if (name && (!me || email !== me)) who = clean(name, 30);
|
|
202
|
+
}
|
|
203
|
+
groups.set(who, [...(groups.get(who) || []), f]);
|
|
204
|
+
}
|
|
205
|
+
return [...groups].map(([who, list]) => `de ${who}: ${listed(list)}`).join('; ');
|
|
206
|
+
}
|
|
207
|
+
|
|
150
208
|
const authorsOf = (root, revs) => [...new Set(revs.flatMap((c) => {
|
|
151
209
|
const a = line(root, ['log', '-1', '--format=%an', c]);
|
|
152
210
|
return a ? [clean(a, 30)] : [];
|
|
@@ -185,10 +243,17 @@ function changedKnowledge(root) {
|
|
|
185
243
|
return [...new Set(files)].filter(isKnowledge);
|
|
186
244
|
}
|
|
187
245
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
246
|
+
// The subject of an auto-commit. Counted by FILE and named by full path: it used to dedupe by
|
|
247
|
+
// basename-without-extension, so eight files read as "… y 4 más" and `01-TOOLS/.gitignore` was
|
|
248
|
+
// indistinguishable from `01-TOOLS/_TEMPLATE/.gitignore`.
|
|
249
|
+
export function commitSummary(files) {
|
|
250
|
+
const paths = [...new Set(files)].sort();
|
|
251
|
+
if (paths.length <= 1) return paths.map((p) => clean(p, 80)).join('');
|
|
252
|
+
const max = 3;
|
|
253
|
+
const shown = paths.slice(0, max).map((p) => clean(p, 80)).join(', ');
|
|
254
|
+
return `${paths.length} ficheros: ${shown}${paths.length > max ? ` y ${paths.length - max} más` : ''}`;
|
|
191
255
|
}
|
|
256
|
+
const summary = commitSummary;
|
|
192
257
|
|
|
193
258
|
/**
|
|
194
259
|
* Commit your knowledge changes so they can go up. Local and fast; returns the new SHA or null.
|
|
@@ -198,13 +263,37 @@ function summary(files) {
|
|
|
198
263
|
* goes up like any other; the files stay modified until the agent commits on a branch (regla A).
|
|
199
264
|
* Repeating it while they stay modified is a no-op upstream — the content is already there.
|
|
200
265
|
*/
|
|
266
|
+
/**
|
|
267
|
+
* Work of the agent's own still uncommitted outside the knowledge folders. While there is, a docs
|
|
268
|
+
* commit of ours on the branch would land AHEAD of the code it describes — field test 3.0.8: an FTD
|
|
269
|
+
* update saying «Fix … probado» committed and pushed while the fix itself was still uncommitted. The
|
|
270
|
+
* harness's own files (`.claude/`, `.rsc.json`) do not count: they may stay untracked for good.
|
|
271
|
+
*/
|
|
272
|
+
function workPending(root) {
|
|
273
|
+
const entries = zlist(git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']));
|
|
274
|
+
for (let i = 0; i < entries.length; i++) {
|
|
275
|
+
const e = entries[i];
|
|
276
|
+
const path = e.slice(3);
|
|
277
|
+
if (e[0] === 'R' || e[0] === 'C') i++;
|
|
278
|
+
if (KNOWLEDGE.some((k) => path.startsWith(k)) || PERSONAL.includes(path)) continue;
|
|
279
|
+
if (/^(\.claude\/|\.rsc\/|\.rsc\.json$|\.worktrees\/)/.test(path)) continue;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
201
285
|
export function commitLocal(root, s) {
|
|
202
286
|
const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
203
287
|
if (branch === 'HEAD') return null; // detached
|
|
204
288
|
const files = changedKnowledge(root).slice(0, MAX_FILES);
|
|
205
289
|
if (!files.length) return null;
|
|
206
|
-
|
|
207
|
-
|
|
290
|
+
// No [skip ci] HERE: this commit lands on the working branch, often as its newest commit, and the
|
|
291
|
+
// next ordinary push of that branch would skip CI on main (E2E 2026-10-07). Only the copy on the
|
|
292
|
+
// exchange branch carries it (see `ship`).
|
|
293
|
+
const message = (names) => `📝 docs(auto): ${summary(names)}`;
|
|
294
|
+
// Closed trunk, or code of the agent's still uncommitted: no commit of ours on the branch. The
|
|
295
|
+
// snapshot goes up the same way and the files stay modified, to be committed WITH that work.
|
|
296
|
+
if ((branch === defaultBranch(root) && trunkClosed(root)) || workPending(root)) {
|
|
208
297
|
const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.snap.index') };
|
|
209
298
|
try {
|
|
210
299
|
if (!run(root, ['read-tree', 'HEAD'], { env }).ok) return null;
|
|
@@ -249,8 +338,22 @@ export function queueCommitted(root, s) {
|
|
|
249
338
|
&& run(root, ['merge-base', '--is-ancestor', s.head, head]).ok;
|
|
250
339
|
const from = valid ? s.head : (remoteDef && line(root, ['merge-base', 'HEAD', remoteDef]));
|
|
251
340
|
if (!head || !from || from === head) return;
|
|
252
|
-
|
|
253
|
-
|
|
341
|
+
// Only what was made HERE (team sim 2026-10-07, D3). `git merge origin/main` on a feature branch
|
|
342
|
+
// brings teammates' commits into `from..head`; walked whole, they were re-sent as ours, clashed with
|
|
343
|
+
// their own newer version on rsc/knowledge, and the person was told «No he podido subir …» about a
|
|
344
|
+
// file they never wrote. So: the branch's own line (`--first-parent`: a merge's second side is not
|
|
345
|
+
// ours), nothing the remote default branch or the exchange branch already has (a rebase onto main
|
|
346
|
+
// puts teammates' commits on the first-parent line too), and never a 📥 sync commit, whose content
|
|
347
|
+
// came FROM rsc/knowledge. Not excluded: other remote branches — your own pushed feature branch
|
|
348
|
+
// holds exactly the commits that must still go up.
|
|
349
|
+
const not = [def && `refs/remotes/origin/${def}`, `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]
|
|
350
|
+
.filter((r) => line(root, ['rev-parse', '--verify', '--quiet', r])).map((r) => `^${r}`);
|
|
351
|
+
const commits = git(root, ['rev-list', '--reverse', '--no-merges', '--first-parent', `${from}..${head}`, ...not, '--', ...KNOWLEDGE]).split('\n').filter(Boolean);
|
|
352
|
+
for (const c of commits) {
|
|
353
|
+
if (s.queue.includes(c) || s.ours.includes(c)) continue;
|
|
354
|
+
if (line(root, ['log', '-1', '--format=%s', c]) === SYNC_SUBJECT) continue;
|
|
355
|
+
s.queue.push(c);
|
|
356
|
+
}
|
|
254
357
|
}
|
|
255
358
|
|
|
256
359
|
// ------------------------------------------------------------------ network: up
|
|
@@ -282,7 +385,8 @@ function replay(root, base, sha, ours = []) {
|
|
|
282
385
|
// to replace whole. (A snapshot from a closed trunk can start from HEAD, behind our own last
|
|
283
386
|
// upload — after an upgrade, or once the chain is lost.)
|
|
284
387
|
const last = line(root, ['log', '-1', '--format=%H', base, '--', f]);
|
|
285
|
-
|
|
388
|
+
const from = blob(root, `${sha}^`, f);
|
|
389
|
+
if (there === from || (last && ours.includes(last)) || behindDefault(root, base, f, there, from, last)) {
|
|
286
390
|
const entry = next && line(root, ['ls-tree', sha, '--', f]);
|
|
287
391
|
const ok = next
|
|
288
392
|
? run(root, ['update-index', '--add', '--cacheinfo', `${entry.split(' ')[0]},${next},${f}`], { env }).ok
|
|
@@ -299,6 +403,47 @@ function replay(root, base, sha, ours = []) {
|
|
|
299
403
|
}
|
|
300
404
|
}
|
|
301
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The exchange branch holds this file only in a version the remote default branch has already gone
|
|
408
|
+
* past — or never held it — and the change starts from the default branch's version: replacing it
|
|
409
|
+
* whole is a fast-forward of the file, not a clash. rsc/knowledge is born from main and never merges
|
|
410
|
+
* it back, so a document merged through a pull request it never carried, then closed on merge
|
|
411
|
+
* (`closeLanded`), met it as a clash. A file the exchange branch once had and then lost is not this
|
|
412
|
+
* case: somebody deleted it there, and that is not ours to undo.
|
|
413
|
+
*/
|
|
414
|
+
function behindDefault(root, base, f, there, from, last) {
|
|
415
|
+
const def = defaultBranch(root);
|
|
416
|
+
const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
|
|
417
|
+
if (!remoteDef || !from || blob(root, remoteDef, f) !== from) return false;
|
|
418
|
+
if (!there) return !last;
|
|
419
|
+
const revs = (line(root, ['rev-list', '-n', '50', remoteDef, '--', f]) || '').split('\n').filter(Boolean);
|
|
420
|
+
return revs.some((c) => blob(root, c, f) === there);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const clashText = (files) => `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
|
|
424
|
+
'Tu versión sigue aquí; hay que juntarlas a mano.';
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The notices, with every clash checked again first (D3). A clash is noted when the push runs and said
|
|
428
|
+
* on a later message; in between the person may have taken the other version, or the two may have
|
|
429
|
+
* converged. A file whose version here — in the working tree, or in the commit that clashed — is now
|
|
430
|
+
* the one on rsc/knowledge is no clash any more, and a notice left with no files is not said.
|
|
431
|
+
*/
|
|
432
|
+
function openNotices(root, s) {
|
|
433
|
+
const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
|
|
434
|
+
const out = [];
|
|
435
|
+
for (const text of s.notices) {
|
|
436
|
+
const c = (s.clashes || []).find((x) => x.text === text);
|
|
437
|
+
if (!c || !tip) { out.push(text); continue; }
|
|
438
|
+
const open = c.files.filter((f) => {
|
|
439
|
+
const there = blob(root, tip, f);
|
|
440
|
+
return worktreeBlob(root, f) !== there && blob(root, c.sha, f) !== there;
|
|
441
|
+
});
|
|
442
|
+
if (open.length) out.push(open.length === c.files.length ? text : clashText(open));
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
302
447
|
/**
|
|
303
448
|
* Put the queued commits on `rsc/knowledge`. Never the default branch; never the working tree or the
|
|
304
449
|
* index. Each queued commit is replayed on top of the exchange branch (`replay`), which is how a
|
|
@@ -328,8 +473,10 @@ export function ship(root, s) {
|
|
|
328
473
|
const tree = replay(root, base, sha, s.ours);
|
|
329
474
|
if (tree === null) {
|
|
330
475
|
const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
|
|
331
|
-
|
|
332
|
-
|
|
476
|
+
const text = clashText(files);
|
|
477
|
+
note(s, text);
|
|
478
|
+
// Kept with what it is about, so it can be checked again before it is said (`openClashes`).
|
|
479
|
+
if (!s.clashes.some((c) => c.text === text)) s.clashes.push({ text, sha, files });
|
|
333
480
|
continue;
|
|
334
481
|
}
|
|
335
482
|
if (tree === line(root, ['rev-parse', `${base}^{tree}`])) { kept.push(sha); continue; } // already there
|
|
@@ -337,7 +484,9 @@ export function ship(root, s) {
|
|
|
337
484
|
// A commit of somebody's own (code + docs) goes up as its knowledge part, under a message that
|
|
338
485
|
// says so and carries [skip ci]: its own message would describe code that is not there, and
|
|
339
486
|
// would run CI on the exchange branch.
|
|
340
|
-
const
|
|
487
|
+
const ownAuto = /^📝 docs\(auto\):/.test(rest[0] || '');
|
|
488
|
+
const body = ownAuto ? [`${rest[0].replace(SKIP_CI, '').trim()} ${SKIP_CI}`, ...rest.slice(1)]
|
|
489
|
+
: rest.join('\n').includes(SKIP_CI) ? rest : [
|
|
341
490
|
`📝 docs(auto): ${summary(zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge))} ${SKIP_CI}`,
|
|
342
491
|
'', `Desde: ${rest[0] || sha.slice(0, 7)}`];
|
|
343
492
|
base = git(root, ['commit-tree', tree, '-p', base, '-F', '-'], {
|
|
@@ -363,8 +512,8 @@ export function ship(root, s) {
|
|
|
363
512
|
// ------------------------------------------------------------------ local: their changes
|
|
364
513
|
|
|
365
514
|
/**
|
|
366
|
-
* Apply what others put on `rsc/knowledge`, from the last fetch
|
|
367
|
-
* branch
|
|
515
|
+
* Apply what others put on `rsc/knowledge`, from the last fetch, into the branch you are on — except a
|
|
516
|
+
* 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
517
|
* branch, so it also carries code, and code reaches you the ordinary way. Only files you have not
|
|
369
518
|
* touched since; a clash is said, never forced. Never a fast-forward: that would move your local
|
|
370
519
|
* default branch onto a commit the remote default branch does not have.
|
|
@@ -389,10 +538,46 @@ export function applyIncoming(root, s) {
|
|
|
389
538
|
const commits = git(root, ['rev-list', `${seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean);
|
|
390
539
|
if (!commits.length) { s.seenBy[branch] = tip; return said; }
|
|
391
540
|
|
|
541
|
+
const def = defaultBranch(root);
|
|
542
|
+
const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
|
|
543
|
+
// Already on the remote default branch exactly as on rsc/knowledge (D3b): the branch gets it from
|
|
544
|
+
// the ordinary merge of main. Brought in here it would only be a duplicate 📥 commit — and on a
|
|
545
|
+
// default branch that is behind, it would turn the next fast-forward pull into a merge.
|
|
546
|
+
//
|
|
547
|
+
// What this does NOT remove (written down so nobody believes it does): a doc brought into a feature
|
|
548
|
+
// branch while it was only on rsc/knowledge, and later merged into main in a DIFFERENT version
|
|
549
|
+
// through another PR, still meets main's version add/add when that branch merges main. Avoiding it
|
|
550
|
+
// means not bringing docs into branches at all, which is the whole point of the sync; usually the
|
|
551
|
+
// newer version reaches the branch through rsc/knowledge first and the adds are identical (clean).
|
|
552
|
+
const onDefault = (f, incoming) => incoming && remoteDef && blob(root, remoteDef, f) === incoming;
|
|
392
553
|
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 ===
|
|
554
|
+
.filter(isKnowledge).filter((f) => !onDefault(f, blob(root, tip, f))).slice(0, MAX_FILES);
|
|
555
|
+
const closed = branch === def && trunkClosed(root);
|
|
395
556
|
const touchedBy = (f) => git(root, ['rev-list', `${seen}..${tip}`, '--', f]).split('\n').filter(Boolean);
|
|
557
|
+
const superseded = (f, mine) => touchedBy(f).some((c) => blob(root, c, f) === mine)
|
|
558
|
+
|| touchedBy(f).some((c) => (line(root, ['log', '-1', '--format=%B', c]) || '').includes(`${CLOSES} ${mine} ${f}`));
|
|
559
|
+
|
|
560
|
+
// A default branch closed for the agent is left exactly as the remote has it (team sim 2026-10-07,
|
|
561
|
+
// D1). Nothing can be committed there, and anything written uncommitted blocks the person's next
|
|
562
|
+
// `git pull`: these same docs reach main inside merged PRs, and git refuses to merge over an
|
|
563
|
+
// untracked file ("untracked working tree files would be overwritten") AND over a modified tracked
|
|
564
|
+
// one, even when the contents are identical (checked by hand on git 2.4x). Writing only new files,
|
|
565
|
+
// or only tracked ones, still leaves one of the two. So: say what is waiting, once per new tip, and
|
|
566
|
+
// leave `seenBy` where it is — the first message on a branch opened from here catches up and
|
|
567
|
+
// commits it there, where a merge of main meets it as an ordinary change.
|
|
568
|
+
if (closed) {
|
|
569
|
+
s.waiting = s.waiting && typeof s.waiting === 'object' ? s.waiting : {};
|
|
570
|
+
if (s.waiting[branch] === tip) return said;
|
|
571
|
+
s.waiting[branch] = tip;
|
|
572
|
+
const pending = know.filter((f) => worktreeBlob(root, f) !== blob(root, tip, f));
|
|
573
|
+
if (!pending.length) return said;
|
|
574
|
+
said.push(`📥 Hay cambios de conocimiento en ${KNOWLEDGE_BRANCH} (${byAuthor(root, pending, touchedBy, s.ours)}). ` +
|
|
575
|
+
`En ${branch} (cerrada) no los escribo, porque bloquearían tu próximo git pull: llegan con el pull ` +
|
|
576
|
+
'cuando se fusionen, o a la rama que abras en su primer mensaje. ' +
|
|
577
|
+
`Para leer uno ya: git show origin/${KNOWLEDGE_BRANCH}:<fichero>.`);
|
|
578
|
+
return said;
|
|
579
|
+
}
|
|
580
|
+
|
|
396
581
|
const take = [];
|
|
397
582
|
const clash = [];
|
|
398
583
|
for (const f of know) {
|
|
@@ -400,9 +585,12 @@ export function applyIncoming(root, s) {
|
|
|
400
585
|
const mine = worktreeBlob(root, f);
|
|
401
586
|
if (mine === incoming) continue;
|
|
402
587
|
const base = blob(root, seen, f);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
588
|
+
const committed = blob(root, 'HEAD', f);
|
|
589
|
+
if (mine === base && committed === base) take.push(f);
|
|
590
|
+
// Untouched here, and a version the exchange branch itself went past — it passed through it, or a
|
|
591
|
+
// close on merge says it replaces it (`CLOSES`). The branch has the merged document as main has it;
|
|
592
|
+
// the close is its newer version, not somebody else's competing edit (field test 3.0.8).
|
|
593
|
+
else if (mine && mine === committed && superseded(f, mine)) take.push(f);
|
|
406
594
|
// Touched here and changed only by your own uploads: your newer version, not a clash.
|
|
407
595
|
else if (touchedBy(f).some((c) => !s.ours.includes(c))) clash.push(f);
|
|
408
596
|
}
|
|
@@ -412,18 +600,8 @@ export function applyIncoming(root, s) {
|
|
|
412
600
|
const present = take.filter((f) => blob(root, tip, f));
|
|
413
601
|
if (present.length) git(root, ['--literal-pathspecs', 'checkout', tip, '--', ...present]);
|
|
414
602
|
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)}.`);
|
|
603
|
+
git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only', '-m', SYNC_SUBJECT, '--', ...take]);
|
|
604
|
+
said.push(`📥 Traídos ${take.length} fichero(s) de conocimiento (${byAuthor(root, take, touchedBy, s.ours)}).`);
|
|
427
605
|
}
|
|
428
606
|
if (clash.length) {
|
|
429
607
|
const who = authorsOf(root, [...new Set(clash.flatMap(touchedBy))].filter((c) => !s.ours.includes(c)));
|
|
@@ -433,6 +611,243 @@ export function applyIncoming(root, s) {
|
|
|
433
611
|
return said;
|
|
434
612
|
}
|
|
435
613
|
|
|
614
|
+
// ------------------------------------------------------------------ close on merge (FTD)
|
|
615
|
+
|
|
616
|
+
// Field test 3.0.8: a feature document written on a feature branch kept saying «pendiente: abrir PR»
|
|
617
|
+
// after its pull request was merged, and carried no author — «¿qué ha hecho cada uno?» got every
|
|
618
|
+
// feature reported as pending and nobody's name, and the session memory kept injecting the stale Next.
|
|
619
|
+
// So the close is not left to the agent remembering: when a branch LANDS on the default branch (the
|
|
620
|
+
// post-merge hook, `worktree-reaper.mjs`), the feature documents that branch wrote are marked done.
|
|
621
|
+
//
|
|
622
|
+
// Where the edit goes is the whole design. Open default branch: a local `📝 docs(auto)` commit, like
|
|
623
|
+
// any other doc commit there. Closed default branch: never on main — neither the agent nor rsc commits
|
|
624
|
+
// there — so it is built on a throwaway index and handed to `ship`, which replays it onto rsc/knowledge
|
|
625
|
+
// like any knowledge change. The team gets it from there, and it reaches main inside the next PR that
|
|
626
|
+
// carries docs. The checkout is not touched, so the closed main stays clean and the next pull works.
|
|
627
|
+
|
|
628
|
+
export const FTD_CLOSE_OPT_OUT = '.no-ftd-close';
|
|
629
|
+
const CLOSE_SUBJECT = '📝 docs(auto): cierre FTD';
|
|
630
|
+
/** Trailer of a close: `Cierra: <blob it replaces> <path>`, so a teammate's branch takes it (applyIncoming). */
|
|
631
|
+
const CLOSES = 'Cierra:';
|
|
632
|
+
const isFtdDoc = (p) => /^02-DOCS\/wiki\/ftd\/[^/]+\.md$/.test(p);
|
|
633
|
+
const MAX_BRANCHES = 50;
|
|
634
|
+
|
|
635
|
+
function splitFrontmatter(text) {
|
|
636
|
+
const t = String(text).replace(/\r\n/g, '\n');
|
|
637
|
+
const m = t.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
638
|
+
return m ? { lines: m[1].split('\n'), body: t.slice(m[0].length) } : { lines: null, body: t };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function fmValue(lines, key) {
|
|
642
|
+
for (const l of lines || []) {
|
|
643
|
+
const m = l.match(/^([A-Za-z_][\w-]*):[ \t]*(.*)$/);
|
|
644
|
+
if (m && m[1] === key) return m[2].trim();
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** The frontmatter of a feature document as plain fields (`author`, `branch`, `status`, …). */
|
|
650
|
+
export function frontmatter(text) {
|
|
651
|
+
const { lines } = splitFrontmatter(text);
|
|
652
|
+
const out = {};
|
|
653
|
+
for (const l of lines || []) {
|
|
654
|
+
const m = l.match(/^([A-Za-z_][\w-]*):[ \t]*(.*)$/);
|
|
655
|
+
if (m) out[m[1]] = m[2].trim();
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const isDoneText = (text) => /^done$/i.test(frontmatter(text).status || '');
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The closed version of a feature document, or null when it is already done (idempotent). Sets
|
|
664
|
+
* `status: done`, `merged`, and `merged_by`/`pr` when known; fills `author`/`branch` only when missing;
|
|
665
|
+
* replaces the Next section with one line saying where and when it landed. Nothing else is rewritten:
|
|
666
|
+
* the evidence is the record.
|
|
667
|
+
*/
|
|
668
|
+
export function closeDoc(text, { trunk, date, sha, author, branch, mergedBy, pr } = {}) {
|
|
669
|
+
const { lines, body } = splitFrontmatter(text);
|
|
670
|
+
const fm = lines ? [...lines] : [];
|
|
671
|
+
if (/^done$/i.test(fmValue(fm, 'status') || '')) return null;
|
|
672
|
+
const set = (key, value, onlyIfMissing = false) => {
|
|
673
|
+
if (value == null || value === '') return;
|
|
674
|
+
const i = fm.findIndex((l) => l.startsWith(`${key}:`));
|
|
675
|
+
if (i < 0) fm.push(`${key}: ${value}`);
|
|
676
|
+
else if (!onlyIfMissing || !fmValue([fm[i]], key)) fm[i] = `${key}: ${value}`;
|
|
677
|
+
};
|
|
678
|
+
set('author', author, true);
|
|
679
|
+
set('branch', branch, true);
|
|
680
|
+
set('status', 'done');
|
|
681
|
+
set('merged', date);
|
|
682
|
+
set('merged_by', mergedBy);
|
|
683
|
+
set('pr', pr ? `#${String(pr).replace(/^#/, '')}` : null);
|
|
684
|
+
const landed = `Fusionado en ${trunk} el ${date} (${sha}).`;
|
|
685
|
+
const next = /^(##[ \t]*(?:Next|Siguiente|Próximo)[^\n]*\n)([\s\S]*?)(?=^##\s|(?![\s\S]))/mu;
|
|
686
|
+
const rest = next.test(body)
|
|
687
|
+
? body.replace(next, (_, heading) => `${heading}${landed}\n\n`)
|
|
688
|
+
: `${body.replace(/\n*$/, '\n')}\n## Next\n${landed}\n`;
|
|
689
|
+
return `---\n${fm.join('\n')}\n---\n${lines ? '' : '\n'}${rest.replace(/\n+$/, '\n')}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const isAncestor = (root, a, b) => run(root, ['merge-base', '--is-ancestor', a, b]).ok;
|
|
693
|
+
|
|
694
|
+
/** Would merging `tip` into `into` change nothing? (squash/rebase merges rewrite identities.) */
|
|
695
|
+
function contains(root, into, tip) {
|
|
696
|
+
const merged = run(root, ['merge-tree', '--write-tree', into, tip]);
|
|
697
|
+
return merged.ok && merged.out.split('\n')[0].trim() === line(root, ['rev-parse', `${into}^{tree}`]);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The branches that landed between `orig` and `head`: in head and not before, by identity (merge,
|
|
702
|
+
* fast-forward) or by content (squash, rebase). Local branches and the remote-tracking ones, the most
|
|
703
|
+
* recent first and bounded — this runs inside somebody's `git pull`.
|
|
704
|
+
*/
|
|
705
|
+
function landedBranches(root, orig, head, def) {
|
|
706
|
+
const refs = (line(root, ['for-each-ref', '--sort=-committerdate', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin']) || '')
|
|
707
|
+
.split('\n').filter(Boolean).slice(0, MAX_BRANCHES * 2);
|
|
708
|
+
const out = [];
|
|
709
|
+
const seen = new Set();
|
|
710
|
+
for (const ref of refs) {
|
|
711
|
+
const name = ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '');
|
|
712
|
+
if (!name || name === 'HEAD' || name === def || name === KNOWLEDGE_BRANCH || seen.has(name)) continue;
|
|
713
|
+
const tip = line(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
714
|
+
if (!tip || isAncestor(root, tip, orig)) continue; // in before, or nothing of its own
|
|
715
|
+
if (isAncestor(root, tip, head) || (contains(root, head, tip) && !contains(root, orig, tip))) {
|
|
716
|
+
seen.add(name);
|
|
717
|
+
out.push({ name, tip });
|
|
718
|
+
}
|
|
719
|
+
if (out.length >= MAX_BRANCHES) break;
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Where and how a branch landed: the first commit of the default branch's own line that has it. */
|
|
725
|
+
function landingOf(root, orig, head, tip) {
|
|
726
|
+
const line1 = (line(root, ['rev-list', '--first-parent', '--reverse', `${orig}..${head}`]) || '').split('\n').filter(Boolean).slice(0, 20);
|
|
727
|
+
const at = line1.find((c) => isAncestor(root, tip, c)) || line1.find((c) => contains(root, c, tip)) || head;
|
|
728
|
+
const [parents = '', committer = '', date = '', subject = ''] = (line(root, ['log', '-1', '--format=%P%x00%cn%x00%cs%x00%s', at]) || '').split('\0');
|
|
729
|
+
const pr = subject.match(/Merge pull request #(\d+)/)?.[1] || subject.match(/\(#(\d+)\)\s*$/)?.[1] || null;
|
|
730
|
+
let mergedBy = null;
|
|
731
|
+
if (parents.trim().split(/\s+/).length > 1 || pr) mergedBy = committer && committer !== 'GitHub' ? committer : null;
|
|
732
|
+
else if (/^merge /.test(line(root, ['reflog', '-1', '--format=%gs', 'HEAD']) || '')) mergedBy = line(root, ['config', 'user.name']);
|
|
733
|
+
return { sha: line(root, ['rev-parse', '--short', at]) || at.slice(0, 7), date: date || new Date().toISOString().slice(0, 10), mergedBy, pr };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The documents a landed branch wrote, closed. A document says whose it is with `branch:`; one of
|
|
738
|
+
* another branch — brought along by a 📥 sync — is not this branch's to close. A document with no
|
|
739
|
+
* frontmatter (written before it existed) counts when a commit of the branch's own, not a sync,
|
|
740
|
+
* touched it; its author is then the git author of the commit that created it.
|
|
741
|
+
*/
|
|
742
|
+
function closuresFor(root, orig, head, def, read) {
|
|
743
|
+
const out = new Map();
|
|
744
|
+
for (const { name, tip } of landedBranches(root, orig, head, def)) {
|
|
745
|
+
const base = line(root, ['merge-base', orig, tip]);
|
|
746
|
+
if (!base) continue;
|
|
747
|
+
const files = zlist(run(root, ['diff', '--name-only', '--no-renames', '-z', base, tip, '--', '02-DOCS/wiki/ftd/']).out)
|
|
748
|
+
.filter(isFtdDoc).filter((f) => !out.has(f) && blob(root, head, f));
|
|
749
|
+
if (!files.length) continue;
|
|
750
|
+
let landing = null;
|
|
751
|
+
for (const f of files) {
|
|
752
|
+
const text = read(f);
|
|
753
|
+
if (text == null || isDoneText(text)) continue;
|
|
754
|
+
const owner = frontmatter(text).branch;
|
|
755
|
+
if (owner && owner !== name) continue;
|
|
756
|
+
if (!owner) {
|
|
757
|
+
const own = (line(root, ['log', '--no-merges', '--format=%s', `${base}..${tip}`, '--', f]) || '').split('\n').filter(Boolean);
|
|
758
|
+
if (!own.some((s) => s !== SYNC_SUBJECT)) continue;
|
|
759
|
+
}
|
|
760
|
+
landing ||= landingOf(root, orig, head, tip);
|
|
761
|
+
const created = (line(root, ['log', '--diff-filter=A', '--format=%an', tip, '--', f]) || '').split('\n').filter(Boolean).pop();
|
|
762
|
+
const closed = closeDoc(text, { trunk: def, ...landing, author: created, branch: name });
|
|
763
|
+
if (closed) out.set(f, closed);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The post-merge entry point: close what just landed. Returns `{ closed, via }` — via `local` (open
|
|
771
|
+
* default branch, committed here) or `knowledge` (closed default branch, queued for rsc/knowledge).
|
|
772
|
+
* Never throws: it runs inside git, mid-pull, and a failed close must never become a failed merge.
|
|
773
|
+
*/
|
|
774
|
+
export function closeLanded(root, { spawnShip = true } = {}) {
|
|
775
|
+
const res = { closed: [], via: null };
|
|
776
|
+
try {
|
|
777
|
+
if (existsSync(join(root, '.rsc', FTD_CLOSE_OPT_OUT)) || !existsSync(join(root, '.rsc.json'))) return res;
|
|
778
|
+
// Not `busy()`: git runs post-merge with MERGE_HEAD still on disk, so that check refused every
|
|
779
|
+
// landing (found end to end, through the real hook). A rebase or a pick in progress is no landing.
|
|
780
|
+
for (const p of ['rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
|
|
781
|
+
const path = line(root, ['rev-parse', '--git-path', p]);
|
|
782
|
+
if (path && existsSync(isAbsolute(path) ? path : join(root, path))) return res;
|
|
783
|
+
}
|
|
784
|
+
const def = defaultBranch(root);
|
|
785
|
+
const branch = line(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
786
|
+
if (!def || branch !== def) return res; // a landing is a landing on the default branch
|
|
787
|
+
const head = line(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
788
|
+
const orig = line(root, ['rev-parse', '--verify', '--quiet', 'ORIG_HEAD^{commit}']);
|
|
789
|
+
if (!head || !orig || orig === head || !isAncestor(root, orig, head)) return res;
|
|
790
|
+
const open = !trunkClosed(root);
|
|
791
|
+
if (!open && inactiveReason(root)) return res; // closed and no rsc/knowledge: nowhere to write
|
|
792
|
+
|
|
793
|
+
let read;
|
|
794
|
+
if (open) {
|
|
795
|
+
// The checkout's own copy, and only when nobody is editing it.
|
|
796
|
+
read = (f) => (worktreeBlob(root, f) === blob(root, 'HEAD', f) ? readFileSync(join(root, f), 'utf8') : null);
|
|
797
|
+
} else {
|
|
798
|
+
// Already closed on rsc/knowledge, or waiting in the queue to go there: not again.
|
|
799
|
+
const s = readState(root);
|
|
800
|
+
let pending = [];
|
|
801
|
+
try { pending = readFileSync(join(root, '.rsc', PENDING), 'utf8').split('\n').filter(Boolean); } catch { /* none */ }
|
|
802
|
+
const elsewhere = [`refs/remotes/origin/${KNOWLEDGE_BRANCH}`, ...s.queue, ...pending];
|
|
803
|
+
const closedElsewhere = (f) => elsewhere.some((rev) => {
|
|
804
|
+
const b = blob(root, rev, f);
|
|
805
|
+
return b && isDoneText(git(root, ['cat-file', 'blob', b]));
|
|
806
|
+
});
|
|
807
|
+
read = (f) => (closedElsewhere(f) ? null : git(root, ['show', `HEAD:${f}`]));
|
|
808
|
+
}
|
|
809
|
+
const docs = closuresFor(root, orig, head, def, read);
|
|
810
|
+
if (!docs.size) return res;
|
|
811
|
+
const files = [...docs.keys()];
|
|
812
|
+
|
|
813
|
+
// One commit on top of HEAD, built on a throwaway index: plumbing, because git runs this hook
|
|
814
|
+
// with the merge state still present and refuses a porcelain commit then.
|
|
815
|
+
const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.close.index') };
|
|
816
|
+
let sha;
|
|
817
|
+
try {
|
|
818
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
819
|
+
git(root, ['read-tree', 'HEAD'], { env });
|
|
820
|
+
const trailers = [];
|
|
821
|
+
for (const [f, text] of docs) {
|
|
822
|
+
const b = git(root, ['hash-object', '-w', '--stdin'], { input: text }).trim();
|
|
823
|
+
const mode = (line(root, ['ls-tree', 'HEAD', '--', f]) || '100644').split(' ')[0];
|
|
824
|
+
git(root, ['update-index', '--cacheinfo', `${mode},${b},${f}`], { env });
|
|
825
|
+
trailers.push(`${CLOSES} ${blob(root, 'HEAD', f)} ${f}`);
|
|
826
|
+
}
|
|
827
|
+
const tree = line(root, ['write-tree'], { env });
|
|
828
|
+
sha = git(root, ['commit-tree', tree, '-p', head, '-F', '-'], { input: `${CLOSE_SUBJECT}: ${summary(files)}\n\n${trailers.join('\n')}\n` }).trim();
|
|
829
|
+
} finally {
|
|
830
|
+
try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (open) {
|
|
834
|
+
// Open: it lands here like any docs(auto) commit — HEAD moves only if nobody moved it meanwhile,
|
|
835
|
+
// then the index and the files follow, for these paths only.
|
|
836
|
+
git(root, ['update-ref', '-m', `rsc: ${CLOSE_SUBJECT}`, 'HEAD', sha, head]);
|
|
837
|
+
for (const [f, text] of docs) writeFileSync(join(root, f), text);
|
|
838
|
+
git(root, ['--literal-pathspecs', 'update-index', '--', ...files]);
|
|
839
|
+
return { closed: files, via: 'local' };
|
|
840
|
+
}
|
|
841
|
+
// Closed: nothing here. Handed to `ship` without the lock, so a push in flight never makes the
|
|
842
|
+
// pull wait (`absorbPending`); it goes up to rsc/knowledge and reaches main in the next PR.
|
|
843
|
+
appendFileSync(join(root, '.rsc', PENDING), `${sha}\n`);
|
|
844
|
+
if (spawnShip) background(root, 'ship');
|
|
845
|
+
return { closed: files, via: 'knowledge' };
|
|
846
|
+
} catch {
|
|
847
|
+
return res;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
436
851
|
// ------------------------------------------------------------------ hook entry points
|
|
437
852
|
|
|
438
853
|
// `RSC_KNOWLEDGE_SYNC_FOREGROUND=1` runs the network half inline: for tests, which would otherwise race
|
|
@@ -455,22 +870,40 @@ function background(root, op) {
|
|
|
455
870
|
} catch { /* the next turn tries again */ }
|
|
456
871
|
}
|
|
457
872
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
873
|
+
/** Refresh what comes down: the exchange branch, and the default branch it is compared against (D3b). */
|
|
874
|
+
function fetchDown(root, timeout) {
|
|
875
|
+
const def = defaultBranch(root);
|
|
876
|
+
const refspecs = [`+refs/heads/${KNOWLEDGE_BRANCH}:refs/remotes/origin/${KNOWLEDGE_BRANCH}`,
|
|
877
|
+
...(def ? [`+refs/heads/${def}:refs/remotes/origin/${def}`] : [])];
|
|
878
|
+
// One round trip; absent upstream (no exchange branch yet) fails it, so the default goes alone.
|
|
879
|
+
if (run(root, ['fetch', '--quiet', 'origin', ...refspecs], { timeout }).ok) return true;
|
|
880
|
+
if (def) run(root, ['fetch', '--quiet', 'origin', refspecs[1]], { timeout });
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const ANNOUNCE = '🔄 La sincronización del conocimiento está activa: lo que cambies en 01-TOOLS/, 02-DOCS/wiki/ ' +
|
|
885
|
+
`y 02-DOCS/attachments/ viaja a tu equipo por la rama ${KNOWLEDGE_BRANCH} (nunca a la principal; allí con ` +
|
|
886
|
+
'[skip ci]) y lo de los demás te llega a tu rama de trabajo. Llega a la principal dentro de vuestras PRs. ' +
|
|
887
|
+
'Para apagarla en este proyecto: `rsc knowledge-sync off`.';
|
|
462
888
|
|
|
463
889
|
/** UserPromptSubmit: apply what was fetched, say what happened, fetch again in the background. */
|
|
464
890
|
export function onRequest(root, { spawnFetch = true } = {}) {
|
|
465
891
|
try {
|
|
466
892
|
if (inactiveReason(root)) return '';
|
|
467
893
|
let fetchDue = false;
|
|
894
|
+
// The background fetch lands AFTER the message that started it, so what comes down is one message
|
|
895
|
+
// late; a session of one message saw nothing (D4). When the last fetch is old, one bounded fetch
|
|
896
|
+
// first — outside the lock, fail-open. Otherwise no network here, so a turn stays cheap.
|
|
897
|
+
const fresh = Date.now() - readState(root).lastFetch > STALE_FETCH_MS
|
|
898
|
+
&& fetchDown(root, FOREGROUND_FETCH_MS);
|
|
468
899
|
const said = locked(root, (s) => {
|
|
469
900
|
const out = [];
|
|
901
|
+
if (fresh) s.lastFetch = Date.now();
|
|
470
902
|
if (!s.announced) { out.push(ANNOUNCE); s.announced = true; }
|
|
471
903
|
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);
|
|
904
|
+
try { out.push(...openNotices(root, s)); } catch { out.push(...s.notices); }
|
|
473
905
|
s.notices = [];
|
|
906
|
+
s.clashes = [];
|
|
474
907
|
if (Date.now() - s.lastFetch > FETCH_EVERY_MS) { s.lastFetch = Date.now(); fetchDue = true; }
|
|
475
908
|
return out;
|
|
476
909
|
});
|
|
@@ -530,8 +963,7 @@ export function work(root, op) {
|
|
|
530
963
|
if (op === 'ship') ship(root, s);
|
|
531
964
|
else if (op === 'fetch') {
|
|
532
965
|
// What comes down comes from the exchange branch; absent upstream is not an error.
|
|
533
|
-
|
|
534
|
-
{ timeout: NET_TIMEOUT_MS });
|
|
966
|
+
fetchDown(root, NET_TIMEOUT_MS);
|
|
535
967
|
if (s.queue.length) ship(root, s);
|
|
536
968
|
}
|
|
537
969
|
} catch (e) {
|
|
@@ -565,7 +997,14 @@ export function hook(target, event, native = {}) {
|
|
|
565
997
|
if (event === 'request') {
|
|
566
998
|
const said = onRequest(root);
|
|
567
999
|
if (!said) return {};
|
|
568
|
-
|
|
1000
|
+
if (target === 'cursor') return { user_message: said };
|
|
1001
|
+
// The person sees it (`systemMessage`), and now the model knows it too: shown only to the person,
|
|
1002
|
+
// a «📥 llegó un cambio» was news the agent could not explain when asked «¿qué cambió?» a turn
|
|
1003
|
+
// later (field test 3.0.4). Same text, so what they each know is the same thing.
|
|
1004
|
+
const context = { hookEventName: target === 'gemini' ? 'BeforeAgent' : 'UserPromptSubmit', additionalContext: `rsc knowledge-sync (already shown to the person):\n${said}` };
|
|
1005
|
+
// DeepSeek Harness drops systemMessage and rsc's bridge turns it into context: one copy is enough.
|
|
1006
|
+
if (target === 'deepseek') return { hookSpecificOutput: { ...context, additionalContext: `rsc knowledge-sync — tell the person in one line:\n${said}` } };
|
|
1007
|
+
return { systemMessage: said, hookSpecificOutput: context };
|
|
569
1008
|
}
|
|
570
1009
|
// `stop_hook_active`: this turn exists because a stop hook asked for it. Nothing new to commit.
|
|
571
1010
|
if (event === 'turn' && !native?.stop_hook_active) onTurn(root);
|
|
@@ -580,5 +1019,8 @@ function stdinJson() {
|
|
|
580
1019
|
if (isMainModule(import.meta.url)) {
|
|
581
1020
|
const [op, a, b] = process.argv.slice(2);
|
|
582
1021
|
if (op === 'hook') process.stdout.write(`${JSON.stringify(hook(a, b, stdinJson()))}\n`);
|
|
583
|
-
else
|
|
1022
|
+
else if (op === 'close') { // the post-merge hook: `close <root>`
|
|
1023
|
+
const r = closeLanded(resolve(a || process.cwd()));
|
|
1024
|
+
for (const f of r.closed) process.stdout.write(`rsc: FTD cerrado ${f}${r.via === 'knowledge' ? ` (vía ${KNOWLEDGE_BRANCH})` : ''}\n`);
|
|
1025
|
+
} else work(a, op); // detached: `ship <root>` or `fetch <root>`
|
|
584
1026
|
}
|