@ericrisco/rsc 2.2.0 → 3.0.1
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 +120 -11
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +13 -0
- package/scripts/rsc.js +67 -1
- package/skills/ftd/SKILL.md +10 -4
- package/skills/ftd/evals/cases.yaml +1 -1
- package/skills/ftd/evals/routing.json +2 -2
- package/skills/ship/SKILL.md +6 -0
- package/skills/suggest/SKILL.md +9 -9
- package/skills/worktrees/SKILL.md +1 -0
- package/targets/branch-guard.mjs +191 -0
- package/targets/claude.js +20 -3
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/hook-once.mjs +5 -6
- package/targets/knowledge-sync.mjs +574 -0
- package/targets/knowledge-wiring.js +172 -0
- package/targets/opt-outs.js +3 -0
- package/targets/session-memory-adapter.mjs +20 -2
- package/targets/session-memory-core.mjs +33 -0
- package/targets/session-start.mjs +11 -0
- package/targets/team-safe-start.mjs +115 -0
- package/targets/trunk-policy.mjs +69 -0
package/targets/claude.js
CHANGED
|
@@ -120,6 +120,9 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
120
120
|
copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
|
|
121
121
|
// session-start imports the shared update check, so it travels as a sibling too.
|
|
122
122
|
copyFileSync(join(HERE, 'auto-update.mjs'), join(paths.projectRoot, '.rsc', 'auto-update.mjs'));
|
|
123
|
+
// team-safe-default: the 3.0 start-up duties and the trunk policy they read, as siblings.
|
|
124
|
+
copyFileSync(join(HERE, 'team-safe-start.mjs'), join(paths.projectRoot, '.rsc', 'team-safe-start.mjs'));
|
|
125
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
123
126
|
copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
|
|
124
127
|
// The one harness file that belongs in the committed tree rather than in `.rsc/`: it is the only
|
|
125
128
|
// thing standing between a clone and seven stack traces, so it has to survive `git clone`.
|
|
@@ -181,6 +184,20 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
181
184
|
);
|
|
182
185
|
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
|
|
183
186
|
|
|
187
|
+
// Branch guard (team-safe-default): the default branch closed for the agent where the project shows
|
|
188
|
+
// it is complex or in production, and no branch switching under another session working in the same
|
|
189
|
+
// checkout. trunk-policy.mjs is its sibling import (and knowledge-sync's); session-memory-core.mjs
|
|
190
|
+
// comes with the memory wiring, and without it the isolation rule simply sees nobody. Fail-open,
|
|
191
|
+
// opt-outs `.rsc/.no-trunk-guard` and `.rsc/.no-worktree-isolation`.
|
|
192
|
+
const bgDest = join(paths.projectRoot, '.rsc', 'branch-guard.mjs');
|
|
193
|
+
copyFileSync(join(HERE, 'branch-guard.mjs'), bgDest);
|
|
194
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
195
|
+
const bgCmd = viaBootstrap('guard', at('.rsc', 'branch-guard.mjs'), `"${P}"`);
|
|
196
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
197
|
+
(e) => !hookWiringOf(e).includes('.rsc/branch-guard.'),
|
|
198
|
+
);
|
|
199
|
+
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: bgCmd }] });
|
|
200
|
+
|
|
184
201
|
|
|
185
202
|
// Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
|
|
186
203
|
// carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
|
|
@@ -213,18 +230,18 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
213
230
|
(e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
|
|
214
231
|
);
|
|
215
232
|
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
|
|
216
|
-
written.push(sgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
233
|
+
written.push(sgDest, gmDest, fgDest, bgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
217
234
|
} else {
|
|
218
235
|
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
219
236
|
if (!settings.hooks[event]) continue;
|
|
220
237
|
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
221
238
|
const body = hookWiringOf(entry);
|
|
222
|
-
return !body.includes('.rsc/ship-guard.') &&
|
|
239
|
+
return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/branch-guard.') &&
|
|
223
240
|
!body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
|
|
224
241
|
});
|
|
225
242
|
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
226
243
|
}
|
|
227
|
-
for (const name of ['ship-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
244
|
+
for (const name of ['ship-guard.mjs', 'branch-guard.mjs', 'trunk-policy.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
228
245
|
rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
|
|
229
246
|
}
|
|
230
247
|
written.push(operationsSuggest);
|
|
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
|
43
43
|
* nothing checks is just drift with a head start.
|
|
44
44
|
*/
|
|
45
45
|
export const PROJECT_OPT_OUTS = [
|
|
46
|
-
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|
package/targets/hook-once.mjs
CHANGED
|
@@ -94,12 +94,11 @@ Classify this turn before acting, and name the lane in one line.
|
|
|
94
94
|
- Asks for information — explain, compare, audit, review, propose? -> Answer.
|
|
95
95
|
Write nothing and create no artifact. Change intent unclear -> ask one
|
|
96
96
|
question and stay read-only; ambiguity slows the lane, it never raises it.
|
|
97
|
-
- Authorises a change?
|
|
98
|
-
tasks checked off
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
count and risk never select it.
|
|
97
|
+
- Authorises a change? YOU pick the lane, say it and why: simple -> FTD (one
|
|
98
|
+
feature document, tasks checked off against observed proof); big or
|
|
99
|
+
interlocking decisions -> SDD (spec + clarify approved, then autopilot or not).
|
|
100
|
+
- Complex/production: no commits on the default branch, branch + PR. Simple:
|
|
101
|
+
default branch. Another session here: .worktrees/<branch>.
|
|
103
102
|
Method: \`ftd\` · chain: \`sdd\` · full decisor in the always-on \`suggest\` body.
|
|
104
103
|
=================================================
|
|
105
104
|
`;
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Knowledge sync: `01-TOOLS/` and `02-DOCS/` stay the same on every machine of a team without anybody
|
|
3
|
+
// thinking about git. What you change goes up when your turn ends; what others change comes down
|
|
4
|
+
// before your next message.
|
|
5
|
+
//
|
|
6
|
+
// It never touches the default branch (team-safe-default D: assume it is protected). Knowledge travels
|
|
7
|
+
// between people through one exchange branch on the remote, `rsc/knowledge`: up to it, down from it,
|
|
8
|
+
// into whatever branch each person is on. It reaches the default branch inside everybody's ordinary
|
|
9
|
+
// pull requests — every working branch already carries it, so whichever merges first takes it there,
|
|
10
|
+
// and the next one carries the same content and does not conflict.
|
|
11
|
+
//
|
|
12
|
+
// On by default, and that is a decision with a price, so the price is kept small on purpose:
|
|
13
|
+
// - it only ever touches KNOWLEDGE paths, and never the personal profile;
|
|
14
|
+
// - it never pushes a commit that is not its own — your unpushed code stays unpushed;
|
|
15
|
+
// - every commit it makes says `[skip ci]`, so a wiki edit does not trigger a deploy;
|
|
16
|
+
// - the first turn says it is on and how to turn it off (`rsc knowledge-sync off`, or
|
|
17
|
+
// `.rsc/.no-knowledge-sync`, a PROJECT switch that travels in `.rsc.json`).
|
|
18
|
+
//
|
|
19
|
+
// Wired on its own (`knowledge-wiring.js`), on the same two lifecycle events the session memory uses —
|
|
20
|
+
// prompt and end of turn — but never inside the memory: the memory promises it makes no network
|
|
21
|
+
// request, and this exists to talk to `origin`. `rsc memory off` does not turn this off, and the other
|
|
22
|
+
// way round.
|
|
23
|
+
//
|
|
24
|
+
// Two speeds, because a hook should answer in seconds and a push may not:
|
|
25
|
+
// - what touches YOUR FILES — committing your changes, applying somebody else's — is local, fast,
|
|
26
|
+
// and runs inside the hook, while nobody else is editing;
|
|
27
|
+
// - what touches THE NETWORK — fetch, push — runs detached, and never touches the working tree or
|
|
28
|
+
// the index: the push is replayed on a throwaway index on top of `rsc/knowledge` (`commit-tree`),
|
|
29
|
+
// 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.
|
|
31
|
+
//
|
|
32
|
+
// Standalone on purpose: hooks are materialized file by file under `.rsc/`, so this imports nothing
|
|
33
|
+
// but Node. Never throws into a hook; whatever goes wrong is said on the next message.
|
|
34
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync,
|
|
37
|
+
unlinkSync, writeFileSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { defaultBranchName, trunkClosed } from './trunk-policy.mjs';
|
|
42
|
+
|
|
43
|
+
/** What syncs on its own. Everything else is ordinary git, decided by a person. */
|
|
44
|
+
export const KNOWLEDGE = Object.freeze(['01-TOOLS/', '02-DOCS/wiki/', '02-DOCS/attachments/']);
|
|
45
|
+
/** One person's dials (`init`, `orient`): never anybody else's business. */
|
|
46
|
+
export const PERSONAL = Object.freeze(['02-DOCS/wiki/harness/user-profile.md']);
|
|
47
|
+
export const OPT_OUT = '.no-knowledge-sync';
|
|
48
|
+
export const SKIP_CI = '[skip ci]';
|
|
49
|
+
/** The exchange branch on the remote. Fixed, in every project (spec, clarify P7). */
|
|
50
|
+
export const KNOWLEDGE_BRANCH = 'rsc/knowledge';
|
|
51
|
+
|
|
52
|
+
const STATE = 'knowledge-sync.json';
|
|
53
|
+
const LOCK = 'knowledge-sync.lock';
|
|
54
|
+
const FETCH_EVERY_MS = 60_000;
|
|
55
|
+
const NET_TIMEOUT_MS = 30_000;
|
|
56
|
+
const LOCAL_TIMEOUT_MS = 4_000;
|
|
57
|
+
const LOCK_STALE_MS = 120_000;
|
|
58
|
+
const MAX_FILES = 200;
|
|
59
|
+
const KEEP_OURS = 200;
|
|
60
|
+
|
|
61
|
+
export const isKnowledge = (path) => KNOWLEDGE.some((k) => path.startsWith(k)) && !PERSONAL.includes(path);
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------------------ git
|
|
64
|
+
|
|
65
|
+
function run(root, args, { timeout = LOCAL_TIMEOUT_MS, input, env } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync('git', args, {
|
|
68
|
+
cwd: root, encoding: 'utf8', timeout, input, maxBuffer: 16 * 1024 * 1024,
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', ...env },
|
|
71
|
+
});
|
|
72
|
+
return { ok: true, status: 0, out, err: '' };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { ok: false, status: e.status ?? -1, out: String(e.stdout || ''), err: String(e.stderr || e.message || '') };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function git(root, args, opts) {
|
|
79
|
+
const r = run(root, args, opts);
|
|
80
|
+
if (!r.ok) throw new Error(`git ${args.find((a) => !a.startsWith('-'))}: ${r.err.trim().split('\n')[0]}`);
|
|
81
|
+
return r.out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const line = (root, args, opts) => { const r = run(root, args, opts); return r.ok ? r.out.trim() : null; };
|
|
85
|
+
const zlist = (out) => out.split('\0').filter(Boolean);
|
|
86
|
+
|
|
87
|
+
// One answer to "which is the default branch", shared with the guard: a copy without the local
|
|
88
|
+
// main/master fallback once let this module commit on a closed main the remote had not seen yet.
|
|
89
|
+
const defaultBranch = (root) => defaultBranchName(root);
|
|
90
|
+
|
|
91
|
+
function busy(root) {
|
|
92
|
+
for (const p of ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
|
|
93
|
+
const path = line(root, ['rev-parse', '--git-path', p]);
|
|
94
|
+
if (path && existsSync(isAbsolute(path) ? path : join(root, path))) return true; // absolute in a linked worktree
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const blob = (root, rev, path) => line(root, ['rev-parse', '--verify', '--quiet', `${rev}:${path}`]);
|
|
100
|
+
const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root, ['hash-object', '--', path]) : null);
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------------------------ state
|
|
103
|
+
|
|
104
|
+
function readState(root) {
|
|
105
|
+
let s = {};
|
|
106
|
+
try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
|
|
107
|
+
return { announced: false, seen: null, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, head: null, ...s };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeState(root, s) {
|
|
111
|
+
const dir = join(root, '.rsc');
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
const tmp = join(dir, `${STATE}.tmp`);
|
|
114
|
+
s.ours = s.ours.slice(-KEEP_OURS);
|
|
115
|
+
writeFileSync(tmp, JSON.stringify(s, null, 2));
|
|
116
|
+
renameSync(tmp, join(dir, STATE));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function note(s, msg) { if (!s.notices.includes(msg)) s.notices.push(msg); }
|
|
120
|
+
|
|
121
|
+
function acquire(root) {
|
|
122
|
+
const path = join(root, '.rsc', LOCK);
|
|
123
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
124
|
+
try { if (Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS) unlinkSync(path); } catch { /* none */ }
|
|
125
|
+
try { closeSync(openSync(path, 'wx')); return true; } catch { return false; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function release(root) { try { unlinkSync(join(root, '.rsc', LOCK)); } catch { /* gone */ } }
|
|
129
|
+
|
|
130
|
+
/** The lock and the state live together: a body that runs without the lock never runs. */
|
|
131
|
+
function locked(root, body) {
|
|
132
|
+
if (!acquire(root)) return null;
|
|
133
|
+
const s = readState(root);
|
|
134
|
+
try { return body(s); } finally { writeState(root, s); release(root); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------------ text
|
|
138
|
+
|
|
139
|
+
const clean = (text, limit = 60) => {
|
|
140
|
+
const t = String(text).replace(/\s+/g, ' ').trim();
|
|
141
|
+
return t.length <= limit ? t : `${t.slice(0, limit - 1)}…`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function listed(paths, max = 5) {
|
|
145
|
+
const shown = paths.slice(0, max).map((p) => clean(p)).join(', ');
|
|
146
|
+
return paths.length > max ? `${shown} y ${paths.length - max} más` : shown;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const authorsOf = (root, revs) => [...new Set(revs.flatMap((c) => {
|
|
150
|
+
const a = line(root, ['log', '-1', '--format=%an', c]);
|
|
151
|
+
return a ? [clean(a, 30)] : [];
|
|
152
|
+
}))].join(', ') || 'alguien';
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------------ enabled
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Why sync is not running here, or null when it is. A reason and not a boolean, so `status` can say
|
|
158
|
+
* which of the quiet conditions applies instead of making the person guess.
|
|
159
|
+
*/
|
|
160
|
+
export function inactiveReason(root) {
|
|
161
|
+
if (!existsSync(join(root, '.rsc.json'))) return 'no-harness';
|
|
162
|
+
if (existsSync(join(root, '.rsc', OPT_OUT))) return 'opted-out';
|
|
163
|
+
const top = line(root, ['rev-parse', '--show-toplevel']);
|
|
164
|
+
if (!top) return 'not-a-repo';
|
|
165
|
+
try { if (realpathSync(top) !== realpathSync(root)) return 'harness-not-at-repo-root'; } catch { return 'not-a-repo'; }
|
|
166
|
+
if (!line(root, ['remote', 'get-url', 'origin'])) return 'no-origin';
|
|
167
|
+
if (!KNOWLEDGE.some((k) => existsSync(join(root, k)))) return 'no-knowledge-folders';
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------------ local: your changes
|
|
172
|
+
|
|
173
|
+
function changedKnowledge(root) {
|
|
174
|
+
const present = KNOWLEDGE.filter((k) => existsSync(join(root, k)) || line(root, ['ls-files', '--', k]));
|
|
175
|
+
if (!present.length) return [];
|
|
176
|
+
const out = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...present]);
|
|
177
|
+
const entries = zlist(out);
|
|
178
|
+
const files = [];
|
|
179
|
+
for (let i = 0; i < entries.length; i++) {
|
|
180
|
+
const e = entries[i];
|
|
181
|
+
files.push(e.slice(3));
|
|
182
|
+
if (e[0] === 'R' || e[0] === 'C') files.push(entries[++i]); // the old name of a rename follows
|
|
183
|
+
}
|
|
184
|
+
return [...new Set(files)].filter(isKnowledge);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function summary(files) {
|
|
188
|
+
const names = [...new Set(files.map((f) => basename(f, extname(f))))].sort();
|
|
189
|
+
return listed(names, 3);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Commit your knowledge changes so they can go up. Local and fast; returns the new SHA or null.
|
|
194
|
+
*
|
|
195
|
+
* On a default branch that is closed for the agent (trunk-policy), nothing is committed there: the
|
|
196
|
+
* commit is built on a throwaway index from HEAD plus the knowledge files and left on no branch. It
|
|
197
|
+
* goes up like any other; the files stay modified until the agent commits on a branch (regla A).
|
|
198
|
+
* Repeating it while they stay modified is a no-op upstream — the content is already there.
|
|
199
|
+
*/
|
|
200
|
+
export function commitLocal(root, s) {
|
|
201
|
+
const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
202
|
+
if (branch === 'HEAD') return null; // detached
|
|
203
|
+
const files = changedKnowledge(root).slice(0, MAX_FILES);
|
|
204
|
+
if (!files.length) return null;
|
|
205
|
+
const message = (names) => `📝 docs(auto): ${summary(names)} ${SKIP_CI}`;
|
|
206
|
+
if (branch === defaultBranch(root) && trunkClosed(root)) {
|
|
207
|
+
const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.snap.index') };
|
|
208
|
+
try {
|
|
209
|
+
if (!run(root, ['read-tree', 'HEAD'], { env }).ok) return null;
|
|
210
|
+
git(root, ['--literal-pathspecs', 'add', '-A', '--', ...files], { env });
|
|
211
|
+
const tree = line(root, ['write-tree'], { env });
|
|
212
|
+
// Chained to the previous snapshot while HEAD has not moved, so each one carries only what
|
|
213
|
+
// changed since: the files stay modified on a closed trunk, and a snapshot against HEAD would
|
|
214
|
+
// re-offer the first version every turn and read the second as a clash (review H1).
|
|
215
|
+
const head = line(root, ['rev-parse', 'HEAD']);
|
|
216
|
+
const prev = s.snap && s.snap.head === head && line(root, ['rev-parse', '--verify', '--quiet', `${s.snap.commit}^{commit}`]) ? s.snap.commit : head;
|
|
217
|
+
if (!tree || tree === line(root, ['rev-parse', `${prev}^{tree}`])) return null;
|
|
218
|
+
const sha = git(root, ['commit-tree', tree, '-p', prev, '-F', '-'], { input: message(files) }).trim();
|
|
219
|
+
s.snap = { head, commit: sha };
|
|
220
|
+
s.queue.push(sha);
|
|
221
|
+
return sha;
|
|
222
|
+
} finally {
|
|
223
|
+
try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
git(root, ['--literal-pathspecs', 'add', '-A', '--', ...files]);
|
|
227
|
+
const staged = zlist(git(root, ['--literal-pathspecs', 'diff', '--cached', '--name-only', '-z', '--', ...files]));
|
|
228
|
+
if (!staged.length) return null;
|
|
229
|
+
git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only', '-m', message(staged), '--', ...staged]);
|
|
230
|
+
const sha = line(root, ['rev-parse', 'HEAD']);
|
|
231
|
+
s.queue.push(sha);
|
|
232
|
+
return sha;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Knowledge the agent (or the person) committed on the branch since the last turn goes up too. Without
|
|
237
|
+
* this only what was still uncommitted when the turn ended travelled, and an agent that commits its
|
|
238
|
+
* feature document with its code in one long turn left nothing to send (E2E 2026-10-04). The first
|
|
239
|
+
* time, and after a branch switch, it starts where the branch left the remote default branch.
|
|
240
|
+
*/
|
|
241
|
+
export function queueCommitted(root, s) {
|
|
242
|
+
const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
243
|
+
if (!branch || branch === 'HEAD') return;
|
|
244
|
+
const head = line(root, ['rev-parse', 'HEAD']);
|
|
245
|
+
const def = defaultBranch(root);
|
|
246
|
+
const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
|
|
247
|
+
const valid = s.head && line(root, ['rev-parse', '--verify', '--quiet', `${s.head}^{commit}`])
|
|
248
|
+
&& run(root, ['merge-base', '--is-ancestor', s.head, head]).ok;
|
|
249
|
+
const from = valid ? s.head : (remoteDef && line(root, ['merge-base', 'HEAD', remoteDef]));
|
|
250
|
+
if (!head || !from || from === head) return;
|
|
251
|
+
const commits = git(root, ['rev-list', '--reverse', '--no-merges', `${from}..${head}`, '--', ...KNOWLEDGE]).split('\n').filter(Boolean);
|
|
252
|
+
for (const c of commits) if (!s.queue.includes(c) && !s.ours.includes(c)) s.queue.push(c);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ------------------------------------------------------------------ network: up
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The tree of `base` with the change of commit `sha` applied, or null if it does not apply cleanly.
|
|
259
|
+
* A throwaway index under `.rsc/` and `git apply --cached`: the real index and the working tree are
|
|
260
|
+
* never read or written, and it works on any git (`merge-tree --merge-base` needs 2.40).
|
|
261
|
+
*/
|
|
262
|
+
function replay(root, base, sha, ours = []) {
|
|
263
|
+
if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^`])) return null;
|
|
264
|
+
// Only the knowledge paths: a commit of the agent's own on a branch also carries code, and code never
|
|
265
|
+
// travels this way.
|
|
266
|
+
const patch = run(root, ['diff-tree', '-p', '--binary', '--full-index', `${sha}^`, sha, '--', ...KNOWLEDGE], { timeout: NET_TIMEOUT_MS });
|
|
267
|
+
if (!patch.ok) return null;
|
|
268
|
+
if (!patch.out.trim()) return line(root, ['rev-parse', `${base}^{tree}`]);
|
|
269
|
+
const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.index') };
|
|
270
|
+
try {
|
|
271
|
+
if (!run(root, ['read-tree', base], { env }).ok) return null;
|
|
272
|
+
// File by file: one that is already there as it should be is skipped, not read as a clash (a
|
|
273
|
+
// teammate's change taken uncommitted on a closed trunk comes back up in the next snapshot), and
|
|
274
|
+
// one real clash does not hold back the rest of the patch's reasoning.
|
|
275
|
+
const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', `${sha}^`, sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
|
|
276
|
+
for (const f of files) {
|
|
277
|
+
const next = blob(root, sha, f);
|
|
278
|
+
const there = blob(root, base, f);
|
|
279
|
+
if (next === there) continue;
|
|
280
|
+
// What is there is what this change started from, or what we ourselves put there last: ours
|
|
281
|
+
// to replace whole. (A snapshot from a closed trunk can start from HEAD, behind our own last
|
|
282
|
+
// upload — after an upgrade, or once the chain is lost.)
|
|
283
|
+
const last = line(root, ['log', '-1', '--format=%H', base, '--', f]);
|
|
284
|
+
if (there === blob(root, `${sha}^`, f) || (last && ours.includes(last))) {
|
|
285
|
+
const entry = next && line(root, ['ls-tree', sha, '--', f]);
|
|
286
|
+
const ok = next
|
|
287
|
+
? run(root, ['update-index', '--add', '--cacheinfo', `${entry.split(' ')[0]},${next},${f}`], { env }).ok
|
|
288
|
+
: run(root, ['update-index', '--force-remove', '--', f], { env }).ok;
|
|
289
|
+
if (!ok) return null;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const one = run(root, ['diff-tree', '-p', '--binary', '--full-index', '--no-renames', `${sha}^`, sha, '--', f], { timeout: NET_TIMEOUT_MS });
|
|
293
|
+
if (!one.ok || !run(root, ['apply', '--cached', '-'], { env, input: one.out, timeout: NET_TIMEOUT_MS }).ok) return null;
|
|
294
|
+
}
|
|
295
|
+
return line(root, ['write-tree'], { env });
|
|
296
|
+
} finally {
|
|
297
|
+
try { unlinkSync(env.GIT_INDEX_FILE); } catch { /* never created */ }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Put the queued commits on `rsc/knowledge`. Never the default branch; never the working tree or the
|
|
303
|
+
* index. Each queued commit is replayed on top of the exchange branch (`replay`), which is how a
|
|
304
|
+
* commit made on `feat/x` goes up without `feat/x`, and how your unpushed code never leaves the
|
|
305
|
+
* machine. The exchange branch is born from the remote default branch the first time, and reborn the
|
|
306
|
+
* same way if somebody deletes it.
|
|
307
|
+
*/
|
|
308
|
+
export function ship(root, s) {
|
|
309
|
+
const def = defaultBranch(root);
|
|
310
|
+
if (!def) return;
|
|
311
|
+
const fetched = run(root, ['fetch', '--quiet', 'origin', def], { timeout: NET_TIMEOUT_MS });
|
|
312
|
+
if (!fetched.ok) return; // offline: the queue goes up on a later turn
|
|
313
|
+
if (!s.queue.length) return;
|
|
314
|
+
const ref = `refs/remotes/origin/${KNOWLEDGE_BRANCH}`;
|
|
315
|
+
|
|
316
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
317
|
+
const k = run(root, ['fetch', '--quiet', 'origin', `+refs/heads/${KNOWLEDGE_BRANCH}:${ref}`], { timeout: NET_TIMEOUT_MS });
|
|
318
|
+
if (!k.ok && !/couldn't find remote ref/i.test(k.err)) return;
|
|
319
|
+
if (!k.ok) run(root, ['update-ref', '-d', ref]); // deleted upstream: reborn from the default branch
|
|
320
|
+
const tip = line(root, ['rev-parse', '--verify', '--quiet', ref]) || line(root, ['rev-parse', `refs/remotes/origin/${def}`]);
|
|
321
|
+
if (!tip) return;
|
|
322
|
+
let base = tip;
|
|
323
|
+
const built = [];
|
|
324
|
+
const kept = [];
|
|
325
|
+
for (const sha of s.queue) {
|
|
326
|
+
if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`])) continue; // rewritten away
|
|
327
|
+
const tree = replay(root, base, sha, s.ours);
|
|
328
|
+
if (tree === null) {
|
|
329
|
+
const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
|
|
330
|
+
note(s, `No he podido subir ${listed(files)}: choca con un cambio que ya está en ${KNOWLEDGE_BRANCH}. ` +
|
|
331
|
+
'Tu versión sigue aquí; hay que juntarlas a mano.');
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (tree === line(root, ['rev-parse', `${base}^{tree}`])) { kept.push(sha); continue; } // already there
|
|
335
|
+
const [name, email, date, ...rest] = git(root, ['log', '-1', '--format=%an%n%ae%n%aI%n%B', sha]).split('\n');
|
|
336
|
+
// A commit of somebody's own (code + docs) goes up as its knowledge part, under a message that
|
|
337
|
+
// says so and carries [skip ci]: its own message would describe code that is not there, and
|
|
338
|
+
// would run CI on the exchange branch.
|
|
339
|
+
const body = rest.join('\n').includes(SKIP_CI) ? rest : [
|
|
340
|
+
`📝 docs(auto): ${summary(zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge))} ${SKIP_CI}`,
|
|
341
|
+
'', `Desde: ${rest[0] || sha.slice(0, 7)}`];
|
|
342
|
+
base = git(root, ['commit-tree', tree, '-p', base, '-F', '-'], {
|
|
343
|
+
input: body.join('\n'),
|
|
344
|
+
env: { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date },
|
|
345
|
+
}).trim();
|
|
346
|
+
built.push(base);
|
|
347
|
+
}
|
|
348
|
+
if (base === tip && line(root, ['rev-parse', '--verify', '--quiet', ref])) { s.ours.push(...kept); s.queue = []; return; }
|
|
349
|
+
const r = run(root, ['push', '--quiet', 'origin', `${base}:refs/heads/${KNOWLEDGE_BRANCH}`], { timeout: NET_TIMEOUT_MS });
|
|
350
|
+
if (r.ok) {
|
|
351
|
+
run(root, ['update-ref', ref, base]);
|
|
352
|
+
s.ours.push(...built, ...kept);
|
|
353
|
+
s.queue = [];
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Somebody pushed to the exchange branch in between: look again once, then say so.
|
|
357
|
+
}
|
|
358
|
+
note(s, `No he podido subir los cambios de conocimiento a ${KNOWLEDGE_BRANCH}. ` +
|
|
359
|
+
'Siguen comiteados aquí; se reintenta en el próximo turno.');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ------------------------------------------------------------------ local: their changes
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Apply what others put on `rsc/knowledge`, from the last fetch — no network here — into whatever
|
|
366
|
+
* branch you are on. Only knowledge paths are looked at: the exchange branch is born from the default
|
|
367
|
+
* branch, so it also carries code, and code reaches you the ordinary way. Only files you have not
|
|
368
|
+
* touched since; a clash is said, never forced. Never a fast-forward: that would move your local
|
|
369
|
+
* default branch onto a commit the remote default branch does not have.
|
|
370
|
+
*/
|
|
371
|
+
export function applyIncoming(root, s) {
|
|
372
|
+
const said = [];
|
|
373
|
+
if (busy(root)) return said;
|
|
374
|
+
const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
375
|
+
if (!branch || branch === 'HEAD') return said;
|
|
376
|
+
const tip = line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${KNOWLEDGE_BRANCH}`]);
|
|
377
|
+
if (!tip) return said;
|
|
378
|
+
if (!s.seen || !line(root, ['rev-parse', '--verify', '--quiet', `${s.seen}^{commit}`])) {
|
|
379
|
+
s.seen = line(root, ['merge-base', 'HEAD', tip]) || tip;
|
|
380
|
+
}
|
|
381
|
+
if (s.seen === tip) return said;
|
|
382
|
+
const paths = [...KNOWLEDGE];
|
|
383
|
+
const theirs = git(root, ['rev-list', `${s.seen}..${tip}`, '--', ...paths]).split('\n').filter(Boolean)
|
|
384
|
+
.filter((c) => !s.ours.includes(c));
|
|
385
|
+
if (!theirs.length) { s.seen = tip; return said; }
|
|
386
|
+
|
|
387
|
+
const know = zlist(git(root, ['diff', '--name-only', '--no-renames', '-z', s.seen, tip, '--', ...paths]))
|
|
388
|
+
.filter(isKnowledge).slice(0, MAX_FILES);
|
|
389
|
+
const who = authorsOf(root, theirs);
|
|
390
|
+
const closed = branch === defaultBranch(root) && trunkClosed(root);
|
|
391
|
+
const take = [];
|
|
392
|
+
const clash = [];
|
|
393
|
+
for (const f of know) {
|
|
394
|
+
const incoming = blob(root, tip, f);
|
|
395
|
+
const mine = worktreeBlob(root, f);
|
|
396
|
+
if (mine === incoming) continue;
|
|
397
|
+
const base = blob(root, s.seen, f);
|
|
398
|
+
// On a closed trunk HEAD lags behind on purpose (what came in was not committed), so only the
|
|
399
|
+
// working tree says whether you touched it (review H2).
|
|
400
|
+
if (mine === base && (closed || blob(root, 'HEAD', f) === base)) take.push(f);
|
|
401
|
+
else clash.push(f);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (take.length) {
|
|
405
|
+
const gone = take.filter((f) => !blob(root, tip, f));
|
|
406
|
+
const present = take.filter((f) => blob(root, tip, f));
|
|
407
|
+
if (present.length) git(root, ['--literal-pathspecs', 'checkout', tip, '--', ...present]);
|
|
408
|
+
if (gone.length) git(root, ['--literal-pathspecs', 'rm', '--quiet', '--', ...gone]);
|
|
409
|
+
// On a default branch closed for the agent nothing is committed there: the files are updated
|
|
410
|
+
// and travel into the next branch the agent opens (regla A).
|
|
411
|
+
if (!closed) {
|
|
412
|
+
git(root, ['--literal-pathspecs', 'commit', '--quiet', '--no-verify', '--only',
|
|
413
|
+
'-m', `📥 docs(auto): sync desde ${KNOWLEDGE_BRANCH} ${SKIP_CI}`, '--', ...take]);
|
|
414
|
+
} else {
|
|
415
|
+
git(root, ['--literal-pathspecs', 'reset', '--quiet', '--', ...take]); // updated, not staged
|
|
416
|
+
}
|
|
417
|
+
said.push(`📥 Traídos ${theirs.length} cambio(s) de conocimiento de ${who}: ${listed(take)}.`);
|
|
418
|
+
}
|
|
419
|
+
if (clash.length) {
|
|
420
|
+
said.push(`${who} cambió ${listed(clash)}, que tú también has tocado. No lo traigo solo: tu versión sigue intacta.`);
|
|
421
|
+
}
|
|
422
|
+
s.seen = tip;
|
|
423
|
+
return said;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ------------------------------------------------------------------ hook entry points
|
|
427
|
+
|
|
428
|
+
// `RSC_KNOWLEDGE_SYNC_FOREGROUND=1` runs the network half inline: for tests, which would otherwise race
|
|
429
|
+
// a detached process for the lock, and for anybody debugging what the background did.
|
|
430
|
+
/**
|
|
431
|
+
* Which program runs the detached worker. Not always `process.execPath`: OpenCode loads plugins in its
|
|
432
|
+
* own Bun-based binary, so there `execPath` is `opencode` — which does not run scripts — and the push
|
|
433
|
+
* silently never happened (found end to end, not by the unit tests). Anything that is not Node gets
|
|
434
|
+
* `node` from the PATH, which every assistant here already needs to run the hooks.
|
|
435
|
+
*/
|
|
436
|
+
export function workerRuntime(execPath = process.execPath, versions = process.versions) {
|
|
437
|
+
return !versions.bun && /^node(?:\.exe)?$/i.test(execPath.split(/[\\/]/).pop()) ? execPath : 'node';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function background(root, op) {
|
|
441
|
+
if (process.env.RSC_KNOWLEDGE_SYNC_FOREGROUND === '1') return work(root, op);
|
|
442
|
+
try {
|
|
443
|
+
const self = fileURLToPath(import.meta.url);
|
|
444
|
+
spawn(workerRuntime(), [self, op, root], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
445
|
+
} catch { /* the next turn tries again */ }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const ANNOUNCE = '🔄 La sincronización del conocimiento está activa: lo que cambies en 01-TOOLS/ y 02-DOCS/ ' +
|
|
449
|
+
`viaja a tu equipo por la rama ${KNOWLEDGE_BRANCH} (nunca a la principal, con [skip ci]) y lo de los demás ` +
|
|
450
|
+
'te llega a la rama en la que estés. Llega a la principal dentro de vuestras PRs. Para apagarla en este ' +
|
|
451
|
+
'proyecto: `rsc knowledge-sync off`.';
|
|
452
|
+
|
|
453
|
+
/** UserPromptSubmit: apply what was fetched, say what happened, fetch again in the background. */
|
|
454
|
+
export function onRequest(root, { spawnFetch = true } = {}) {
|
|
455
|
+
try {
|
|
456
|
+
if (inactiveReason(root)) return '';
|
|
457
|
+
let fetchDue = false;
|
|
458
|
+
const said = locked(root, (s) => {
|
|
459
|
+
const out = [];
|
|
460
|
+
if (!s.announced) { out.push(ANNOUNCE); s.announced = true; }
|
|
461
|
+
try { out.push(...applyIncoming(root, s)); } catch (e) { out.push(`La sincronización del conocimiento falló al traer cambios: ${clean(e.message, 160)}`); }
|
|
462
|
+
out.push(...s.notices);
|
|
463
|
+
s.notices = [];
|
|
464
|
+
if (Date.now() - s.lastFetch > FETCH_EVERY_MS) { s.lastFetch = Date.now(); fetchDue = true; }
|
|
465
|
+
return out;
|
|
466
|
+
});
|
|
467
|
+
if (fetchDue && spawnFetch) background(root, 'fetch'); // after the lock is released, or it would find it taken
|
|
468
|
+
return (said || []).join('\n');
|
|
469
|
+
} catch {
|
|
470
|
+
return '';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Stop: commit your knowledge changes here, then push them in the background. */
|
|
475
|
+
export function onTurn(root, { spawnShip = true } = {}) {
|
|
476
|
+
try {
|
|
477
|
+
if (inactiveReason(root) || busy(root)) return;
|
|
478
|
+
const pending = locked(root, (s) => {
|
|
479
|
+
try { queueCommitted(root, s); } catch { /* the next turn looks again */ }
|
|
480
|
+
try { commitLocal(root, s); } catch (e) { note(s, `La sincronización del conocimiento no pudo comitear: ${clean(e.message, 160)}`); }
|
|
481
|
+
s.head = line(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) || s.head;
|
|
482
|
+
return s.queue.length;
|
|
483
|
+
});
|
|
484
|
+
if (pending && spawnShip) background(root, 'ship');
|
|
485
|
+
} catch { /* a hook never takes the turn down */ }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** For `rsc knowledge-sync status`. */
|
|
489
|
+
export function knowledgeStatus(root) {
|
|
490
|
+
const reason = inactiveReason(root);
|
|
491
|
+
const s = readState(root);
|
|
492
|
+
return {
|
|
493
|
+
active: !reason,
|
|
494
|
+
reason,
|
|
495
|
+
branch: line(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
496
|
+
defaultBranch: defaultBranch(root),
|
|
497
|
+
exchangeBranch: KNOWLEDGE_BRANCH,
|
|
498
|
+
queued: s.queue.length,
|
|
499
|
+
pendingNotices: s.notices.length,
|
|
500
|
+
lastFetch: s.lastFetch ? new Date(s.lastFetch).toISOString() : null,
|
|
501
|
+
paths: [...KNOWLEDGE],
|
|
502
|
+
neverSynced: [...PERSONAL],
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ------------------------------------------------------------------ detached worker
|
|
507
|
+
|
|
508
|
+
function isMainModule(metaUrl) {
|
|
509
|
+
const invoked = process.argv[1];
|
|
510
|
+
if (!invoked) return false;
|
|
511
|
+
const self = fileURLToPath(metaUrl);
|
|
512
|
+
try { return realpathSync(self) === realpathSync(invoked); } catch { return self === invoked; }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** What the detached process does: `ship` pushes the queue, `fetch` refreshes and retries the queue. */
|
|
516
|
+
export function work(root, op) {
|
|
517
|
+
if (!root || inactiveReason(root)) return;
|
|
518
|
+
locked(root, (s) => {
|
|
519
|
+
try {
|
|
520
|
+
if (op === 'ship') ship(root, s);
|
|
521
|
+
else if (op === 'fetch') {
|
|
522
|
+
// What comes down comes from the exchange branch; absent upstream is not an error.
|
|
523
|
+
run(root, ['fetch', '--quiet', 'origin', `+refs/heads/${KNOWLEDGE_BRANCH}:refs/remotes/origin/${KNOWLEDGE_BRANCH}`],
|
|
524
|
+
{ timeout: NET_TIMEOUT_MS });
|
|
525
|
+
if (s.queue.length) ship(root, s);
|
|
526
|
+
}
|
|
527
|
+
} catch (e) {
|
|
528
|
+
note(s, `La sincronización del conocimiento falló: ${clean(e.message, 160)}`);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ------------------------------------------------------------------ the hook
|
|
534
|
+
|
|
535
|
+
/** The project is the nearest ancestor holding `.rsc.json`, as for the session memory. */
|
|
536
|
+
function nearestHarness(dir) {
|
|
537
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
538
|
+
if (existsSync(join(current, '.rsc.json'))) return current;
|
|
539
|
+
if (dirname(current) === current) return dir;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const isRemote = (target, native) => (target === 'cursor' && (native?.is_background_agent === true || native?.isBackgroundAgent === true))
|
|
544
|
+
|| process.env.RSC_REMOTE_AGENT === '1' || process.env.CURSOR_CLOUD_AGENT === '1' || process.env.CODEX_CLOUD_AGENT === '1';
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* One lifecycle event from an assistant. `request` = the person sent a message; `turn` = the agent
|
|
548
|
+
* finished. What is said goes to the PERSON (`systemMessage`, which every event accepts; Cursor's
|
|
549
|
+
* `user_message`), never into the model's context.
|
|
550
|
+
*/
|
|
551
|
+
export function hook(target, event, native = {}) {
|
|
552
|
+
try {
|
|
553
|
+
if (isRemote(target, native)) return {};
|
|
554
|
+
const root = nearestHarness(resolve(native.cwd || process.env.RSC_PROJECT_CWD || process.cwd()));
|
|
555
|
+
if (event === 'request') {
|
|
556
|
+
const said = onRequest(root);
|
|
557
|
+
if (!said) return {};
|
|
558
|
+
return target === 'cursor' ? { user_message: said } : { systemMessage: said };
|
|
559
|
+
}
|
|
560
|
+
// `stop_hook_active`: this turn exists because a stop hook asked for it. Nothing new to commit.
|
|
561
|
+
if (event === 'turn' && !native?.stop_hook_active) onTurn(root);
|
|
562
|
+
} catch { /* a hook never takes the turn down */ }
|
|
563
|
+
return {};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function stdinJson() {
|
|
567
|
+
try { const raw = readFileSync(0, 'utf8'); return raw.trim() ? JSON.parse(raw) : {}; } catch { return {}; }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (isMainModule(import.meta.url)) {
|
|
571
|
+
const [op, a, b] = process.argv.slice(2);
|
|
572
|
+
if (op === 'hook') process.stdout.write(`${JSON.stringify(hook(a, b, stdinJson()))}\n`);
|
|
573
|
+
else work(a, op); // detached: `ship <root>` or `fetch <root>`
|
|
574
|
+
}
|