@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Wiring for knowledge sync (`knowledge-sync.mjs`): two lifecycle events per assistant — the person
|
|
2
|
+
// sends a message, the agent finishes — in the same project-local config files the session memory
|
|
3
|
+
// uses, under a needle of its own so either can be wired or removed without touching the other.
|
|
4
|
+
//
|
|
5
|
+
// Kept apart from `memory.js` on purpose. The memory's promise is that it makes no network request,
|
|
6
|
+
// and a test holds every file it wires to that; knowledge sync exists to talk to `origin`. Riding
|
|
7
|
+
// inside the memory would have broken the promise and tied the two switches together.
|
|
8
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const NEEDLE = '.rsc/knowledge-sync.mjs';
|
|
15
|
+
const PLUGIN_MARKER = 'rsc-knowledge:managed';
|
|
16
|
+
|
|
17
|
+
/** Same files as the memory: project-local, untracked, the assistant's own hook config. */
|
|
18
|
+
const CONFIG = Object.freeze({
|
|
19
|
+
claude: '.claude/settings.local.json',
|
|
20
|
+
codex: '.codex/hooks.json',
|
|
21
|
+
cursor: '.cursor/hooks.json',
|
|
22
|
+
gemini: '.gemini/settings.json',
|
|
23
|
+
opencode: '.opencode/plugins/rsc-knowledge.js',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const EVENTS = Object.freeze({
|
|
27
|
+
claude: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
|
|
28
|
+
codex: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
|
|
29
|
+
gemini: [['BeforeAgent', 'request'], ['AfterAgent', 'turn']],
|
|
30
|
+
cursor: [['beforeSubmitPrompt', 'request'], ['afterAgentResponse', 'turn']],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const KNOWLEDGE_TARGETS = Object.freeze(Object.keys(CONFIG));
|
|
34
|
+
|
|
35
|
+
const git = (cwd, args) => {
|
|
36
|
+
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
37
|
+
};
|
|
38
|
+
const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
39
|
+
const tracked = (cwd, path) => Boolean(git(cwd, ['ls-files', '--', rel(cwd, path)]));
|
|
40
|
+
|
|
41
|
+
function exclude(cwd, path) {
|
|
42
|
+
if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
|
|
43
|
+
const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
44
|
+
if (!value) return;
|
|
45
|
+
const file = isAbsolute(value) ? value : resolve(cwd, value);
|
|
46
|
+
const pattern = `/${rel(cwd, path)}`;
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
48
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
49
|
+
if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readConfig(path) {
|
|
53
|
+
if (!existsSync(path)) return {};
|
|
54
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const validHookConfig = (c) => Boolean(c) && typeof c === 'object' && !Array.isArray(c)
|
|
58
|
+
&& (!Object.hasOwn(c, 'hooks') || (c.hooks && typeof c.hooks === 'object' && !Array.isArray(c.hooks) && Object.values(c.hooks).every(Array.isArray)));
|
|
59
|
+
|
|
60
|
+
function handler(target, event) {
|
|
61
|
+
const script = target === 'claude'
|
|
62
|
+
? '"${CLAUDE_PROJECT_DIR}/.rsc/knowledge-sync.mjs"'
|
|
63
|
+
: target === 'codex'
|
|
64
|
+
? '"$(git rev-parse --show-toplevel)/.rsc/knowledge-sync.mjs"'
|
|
65
|
+
: '".rsc/knowledge-sync.mjs"';
|
|
66
|
+
const value = { type: 'command', command: `node ${script} hook ${target} ${event}`, timeout: target === 'gemini' ? 10000 : 10 };
|
|
67
|
+
if (target === 'codex') value.commandWindows = `node "$((git rev-parse --show-toplevel).Trim())/.rsc/knowledge-sync.mjs" hook ${target} ${event}`;
|
|
68
|
+
if (target === 'gemini') value.name = `rsc-knowledge-${event}`;
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function strip(config) {
|
|
73
|
+
if (!validHookConfig(config) || !config.hooks) return config;
|
|
74
|
+
for (const event of Object.keys(config.hooks)) {
|
|
75
|
+
config.hooks[event] = config.hooks[event].filter((e) => !JSON.stringify(e).replaceAll('\\\\', '/').includes(NEEDLE));
|
|
76
|
+
if (!config.hooks[event].length) delete config.hooks[event];
|
|
77
|
+
}
|
|
78
|
+
if (!Object.keys(config.hooks).length) delete config.hooks;
|
|
79
|
+
return config;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PLUGIN = `// ${PLUGIN_MARKER}
|
|
83
|
+
// rsc knowledge sync for OpenCode. OpenCode has no prompt event and no channel to the person, so what
|
|
84
|
+
// sync has to say goes to the model, to be relayed. Cheap on every call: the fetch is throttled and
|
|
85
|
+
// every notice is said once. Turn it off with \`rsc knowledge-sync off\`.
|
|
86
|
+
import { onRequest, onTurn } from '../../.rsc/knowledge-sync.mjs';
|
|
87
|
+
|
|
88
|
+
export const RscKnowledgePlugin = async ({ directory, worktree }) => {
|
|
89
|
+
const cwd = worktree || directory;
|
|
90
|
+
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
91
|
+
return {
|
|
92
|
+
'experimental.chat.system.transform': async (_input, output) => {
|
|
93
|
+
if (!local()) return;
|
|
94
|
+
try {
|
|
95
|
+
const said = onRequest(cwd);
|
|
96
|
+
if (said && Array.isArray(output?.system)) output.system.push(\`Cuéntale esto al usuario en una línea:\\n\${said}\`);
|
|
97
|
+
} catch { /* fail open */ }
|
|
98
|
+
},
|
|
99
|
+
event: async ({ event }) => {
|
|
100
|
+
if (event?.type === 'session.idle' && local()) { try { onTurn(cwd); } catch { /* fail open */ } }
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
export function knowledgeManagedPaths(target, cwd = process.cwd()) {
|
|
107
|
+
if (!CONFIG[target]) return [];
|
|
108
|
+
return [join(cwd, '.rsc', 'knowledge-sync.mjs'), join(cwd, '.rsc', 'trunk-policy.mjs'), join(cwd, ...CONFIG[target].split('/'))];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Wire it. Always, when the assistant supports it: being off is the runtime's `.no-knowledge-sync`. */
|
|
112
|
+
export function wireKnowledge(target, cwd = process.cwd()) {
|
|
113
|
+
if (!CONFIG[target]) return { mode: 'unsupported', paths: [] };
|
|
114
|
+
const configPath = join(cwd, ...CONFIG[target].split('/'));
|
|
115
|
+
if (tracked(cwd, configPath)) return { mode: 'degraded', reason: 'config-tracked', paths: [] };
|
|
116
|
+
if (target === 'opencode' && existsSync(configPath) && !readFileSync(configPath, 'utf8').includes(PLUGIN_MARKER)) {
|
|
117
|
+
return { mode: 'degraded', reason: 'plugin-collision', paths: [] };
|
|
118
|
+
}
|
|
119
|
+
const config = target === 'opencode' ? null : readConfig(configPath);
|
|
120
|
+
if (target !== 'opencode' && !validHookConfig(config)) return { mode: 'degraded', reason: 'config-invalid', paths: [] };
|
|
121
|
+
|
|
122
|
+
const script = join(cwd, '.rsc', 'knowledge-sync.mjs');
|
|
123
|
+
mkdirSync(dirname(script), { recursive: true });
|
|
124
|
+
copyFileSync(join(HERE, 'knowledge-sync.mjs'), script);
|
|
125
|
+
// Its sibling import: whether the default branch is closed decides if sync may commit there.
|
|
126
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(cwd, '.rsc', 'trunk-policy.mjs'));
|
|
127
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
128
|
+
if (target === 'opencode') writeFileSync(configPath, PLUGIN);
|
|
129
|
+
else {
|
|
130
|
+
strip(config);
|
|
131
|
+
config.hooks ||= {};
|
|
132
|
+
if (target === 'cursor') config.version ||= 1;
|
|
133
|
+
for (const [event, op] of EVENTS[target]) {
|
|
134
|
+
config.hooks[event] ||= [];
|
|
135
|
+
const h = handler(target, op);
|
|
136
|
+
if (target === 'cursor') { delete h.type; delete h.timeout; config.hooks[event].push(h); }
|
|
137
|
+
else config.hooks[event].push({ hooks: [h] });
|
|
138
|
+
}
|
|
139
|
+
// A fixed event order. The memory rewires by stripping and re-appending its own events, which
|
|
140
|
+
// moves them behind ours; without this, install and re-install write the same hooks in a
|
|
141
|
+
// different order and the onboarding receipt reads it as drift ("governed content differs").
|
|
142
|
+
config.hooks = Object.fromEntries(Object.keys(config.hooks).sort().map((k) => [k, config.hooks[k]]));
|
|
143
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
144
|
+
}
|
|
145
|
+
const paths = knowledgeManagedPaths(target, cwd);
|
|
146
|
+
for (const path of paths) exclude(cwd, path);
|
|
147
|
+
return { mode: 'wired', paths };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function unwireKnowledge(target, cwd = process.cwd()) {
|
|
151
|
+
if (!CONFIG[target]) return [];
|
|
152
|
+
const path = join(cwd, ...CONFIG[target].split('/'));
|
|
153
|
+
const touched = [];
|
|
154
|
+
if (target === 'opencode') {
|
|
155
|
+
if (existsSync(path) && readFileSync(path, 'utf8').includes(PLUGIN_MARKER)) { rmSync(path, { force: true }); touched.push(path); }
|
|
156
|
+
} else if (existsSync(path) && readFileSync(path, 'utf8').replaceAll('\\\\', '/').includes(NEEDLE)) {
|
|
157
|
+
const config = readConfig(path);
|
|
158
|
+
if (config) { strip(config); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); touched.push(path); }
|
|
159
|
+
}
|
|
160
|
+
return touched;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function knowledgeArtifactsPresent(target, cwd = process.cwd()) {
|
|
164
|
+
if (!CONFIG[target]) return [];
|
|
165
|
+
const out = [join(cwd, '.rsc', 'knowledge-sync.mjs')].filter(existsSync);
|
|
166
|
+
const path = join(cwd, ...CONFIG[target].split('/'));
|
|
167
|
+
if (existsSync(path)) {
|
|
168
|
+
const body = readFileSync(path, 'utf8').replaceAll('\\\\', '/');
|
|
169
|
+
if (body.includes(NEEDLE) || body.includes(PLUGIN_MARKER)) out.push(path);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
package/targets/opt-outs.js
CHANGED
|
@@ -23,8 +23,11 @@ export const PROJECT_OPT_OUTS = [
|
|
|
23
23
|
'danger-guard', // foot-gun denial, keyed to the committed user profile
|
|
24
24
|
'feature-gate', // per-turn re-injection of the SDD gate
|
|
25
25
|
'gitmoji', // the commit-message convention
|
|
26
|
+
'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
|
|
26
27
|
'ship-guard', // branch/trunk discipline
|
|
28
|
+
'trunk-guard', // «desbloquea main»: the default branch open for the agent, for the whole team
|
|
27
29
|
'worktree-cleanup', // the reaper's notice (it only ever names, never acts)
|
|
30
|
+
'worktree-isolation', // «no uses worktrees»: same-folder sessions allowed to share a checkout
|
|
28
31
|
];
|
|
29
32
|
|
|
30
33
|
/** Decisions of one machine: never written to the manifest, never applied from it. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
|
-
import { capture, resume } from './session-memory-core.mjs';
|
|
5
|
+
import { capture, otherActiveSessions, resume } from './session-memory-core.mjs';
|
|
6
6
|
|
|
7
7
|
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
|
|
8
8
|
|
|
@@ -88,6 +88,23 @@ function nativeOutput(target, eventName, context = '', notice = null, compaction
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* team-safe-default B, the half the skills act on: when another session is working in this same
|
|
93
|
+
* checkout, the model is told — every message, while it lasts — to do new work in a worktree. Local
|
|
94
|
+
* journal only, no network (the memory's promise stands). `.no-worktree-isolation` turns it off.
|
|
95
|
+
*/
|
|
96
|
+
export function isolationContext({ project, here, sessionId, target }) {
|
|
97
|
+
if (existsSync(join(project, '.rsc', '.no-worktree-isolation'))) return '';
|
|
98
|
+
const others = otherActiveSessions({ cwd: project, worktreeCwd: here, sessionId, target });
|
|
99
|
+
if (!others.length) return '';
|
|
100
|
+
const who = others.map((o) => `${o.target}${o.branch ? ` en «${o.branch}»` : ''}`).join(', ');
|
|
101
|
+
return `rsc · aislamiento: otra sesión está trabajando en esta misma carpeta (${who}). ` +
|
|
102
|
+
'Cualquier cambio de esta petición se hace en un worktree dentro del proyecto: ' +
|
|
103
|
+
'`git worktree add .worktrees/<rama> -b <rama>` y se trabaja dentro de `.worktrees/<rama>/`. ' +
|
|
104
|
+
'No cambies de rama en esta carpeta. Dile a la persona en una línea dónde estás trabajando, para que ' +
|
|
105
|
+
'abra o levante el proyecto desde allí.';
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
export function contextFromNativeOutput(target, output = {}) {
|
|
92
109
|
if (target === 'cursor') return output.additional_context || '';
|
|
93
110
|
if (target === 'opencode') return output.context || '';
|
|
@@ -135,8 +152,9 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
135
152
|
// nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
|
|
136
153
|
// nothing.
|
|
137
154
|
const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
|
|
155
|
+
const isolation = event === 'request' ? isolationContext({ project, here, sessionId: id, target }) : '';
|
|
138
156
|
return {
|
|
139
|
-
output: silenced ? {} : nativeOutput(target, eventName,
|
|
157
|
+
output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
|
|
140
158
|
capture: captured,
|
|
141
159
|
remote: false,
|
|
142
160
|
degraded: false,
|
|
@@ -258,6 +258,39 @@ function activeConcurrent(records, sessionId, target, worktree, now) {
|
|
|
258
258
|
&& !record?.timestamps?.completedAt && new Date(record?.timestamps?.updatedAt || 0).getTime() >= recent);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/** How long a session counts as "working here" after its last recorded event (team-safe-default B). */
|
|
262
|
+
export const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Other sessions working in THIS checkout right now: any assistant with rsc, same worktree, an event
|
|
266
|
+
* in the last 30 minutes, not closed, and not the asking session itself. It answers WHO is working,
|
|
267
|
+
* never WHAT is in the project (constitution P3), reads only the local journal (no network, no
|
|
268
|
+
* content), and its worst failure is one worktree too many or too few.
|
|
269
|
+
*
|
|
270
|
+
* A session that only talks has no record — the journal only writes when there is work — and that is
|
|
271
|
+
* the right blind spot: the harm this prevents is switching branches under a session that is EDITING.
|
|
272
|
+
*/
|
|
273
|
+
export function otherActiveSessions(input = {}) {
|
|
274
|
+
try {
|
|
275
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
276
|
+
const top = git(resolve(input.worktreeCwd || cwd), ['rev-parse', '--show-toplevel']);
|
|
277
|
+
if (!top.ok) return [];
|
|
278
|
+
const here = resolve(top.out);
|
|
279
|
+
const store = chooseMemoryRoot(cwd);
|
|
280
|
+
if (!existsSync(store.root)) return [];
|
|
281
|
+
const now = new Date(input.now || Date.now()).getTime();
|
|
282
|
+
const windowMs = Number.isFinite(input.windowMs) ? input.windowMs : ACTIVE_WINDOW_MS;
|
|
283
|
+
return readRecords(store.root)
|
|
284
|
+
.filter((r) => r.worktree === here)
|
|
285
|
+
.filter((r) => !(r.sessionId === input.sessionId && r.target === input.target))
|
|
286
|
+
.filter((r) => !r.timestamps?.completedAt)
|
|
287
|
+
.filter((r) => now - new Date(r.timestamps?.updatedAt || 0).getTime() <= windowMs)
|
|
288
|
+
.map((r) => ({ target: r.target, sessionId: r.sessionId, branch: r.branch, updatedAt: r.timestamps.updatedAt }));
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
261
294
|
export function validateSessionRecord(record) {
|
|
262
295
|
const errors = [];
|
|
263
296
|
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['record: object required'];
|
|
@@ -253,6 +253,17 @@ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
|
|
|
253
253
|
} catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// team-safe-default (3.0): rescue commits stranded on a closed default branch, move old sibling
|
|
257
|
+
// worktrees inside the project, and say once what 3.0 changed. Each is silent when there is nothing.
|
|
258
|
+
if (has('.git')) {
|
|
259
|
+
try {
|
|
260
|
+
const T = await import('./team-safe-start.mjs');
|
|
261
|
+
for (const said of [T.teamSafeAnnouncement(root), await T.rescueTrunkCommits(root), await T.relocateOldWorktrees(root)]) {
|
|
262
|
+
if (said) process.stdout.write(said.startsWith('\n') ? said : `\n${said}\n`);
|
|
263
|
+
}
|
|
264
|
+
} catch { /* module missing → nothing to do; never worth breaking startup for */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
256
267
|
// Update check and auto-update: the rule lives in auto-update.mjs, shared with the other assistants.
|
|
257
268
|
// Disable with RSC_NO_UPDATE_CHECK=1.
|
|
258
269
|
if (!process.env.RSC_NO_UPDATE_CHECK) process.stdout.write(await updateNotice(root));
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// What a session start does for team-safe-default (rsc 3.0), beyond saying hello:
|
|
2
|
+
//
|
|
3
|
+
// rescueTrunkCommits — commits sitting on a CLOSED default branch that never reached the remote
|
|
4
|
+
// (left there by an agent before 3.0) move to a `rescue/…` branch, and the
|
|
5
|
+
// default branch goes back to what the remote has. Spec clarify P4.
|
|
6
|
+
// relocateOldWorktrees — worktrees older versions made OUTSIDE the project (sibling folders) move
|
|
7
|
+
// into `.worktrees/`. Only the ones rsc made, never one a session is working
|
|
8
|
+
// in, and a failure leaves it where it was. Spec clarify P6.
|
|
9
|
+
// teamSafeAnnouncement — once per project and machine, what changed in 3.0 and how to turn each
|
|
10
|
+
// part off. Spec, global criterion.
|
|
11
|
+
//
|
|
12
|
+
// Each returns the text to say, or ''. None throws: a session start is never worth breaking.
|
|
13
|
+
// Standalone siblings under `.rsc/`: trunk-policy.mjs, worktree-reaper.mjs, session-memory-core.mjs.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, join, sep } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const git = (root, args) => {
|
|
19
|
+
try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 }).trim(); }
|
|
20
|
+
catch { return null; }
|
|
21
|
+
};
|
|
22
|
+
const ok = (root, args) => git(root, args) !== null;
|
|
23
|
+
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
24
|
+
const stamp = () => new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/^(\d{8})(\d{4})$/, '$1-$2');
|
|
25
|
+
|
|
26
|
+
const RESCUE_MARK = '.team-safe-3-rescue';
|
|
27
|
+
|
|
28
|
+
/** Once per project and machine, the first session with 3.0 (spec P4): after that, commits on the
|
|
29
|
+
* default branch are the person's own business (P3), never moved again (review M1). */
|
|
30
|
+
export async function rescueTrunkCommits(root) {
|
|
31
|
+
try {
|
|
32
|
+
const mark = join(root, '.rsc', RESCUE_MARK);
|
|
33
|
+
if (existsSync(mark)) return '';
|
|
34
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
35
|
+
writeFileSync(mark, `${new Date().toISOString()}\n`);
|
|
36
|
+
const { trunkPolicy, defaultBranchName } = await import(new URL('./trunk-policy.mjs', import.meta.url));
|
|
37
|
+
const branch = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
38
|
+
const trunk = defaultBranchName(root);
|
|
39
|
+
if (!branch || !trunk || branch !== trunk || !trunkPolicy(root).closed) return '';
|
|
40
|
+
const remote = `refs/remotes/origin/${trunk}`;
|
|
41
|
+
if (!git(root, ['rev-parse', '--verify', '--quiet', remote])) return '';
|
|
42
|
+
const ahead = Number(git(root, ['rev-list', '--count', `${remote}..HEAD`]) || 0);
|
|
43
|
+
if (!ahead) return '';
|
|
44
|
+
const name = `rescue/${trunk}-${stamp()}`;
|
|
45
|
+
if (!ok(root, ['branch', name, 'HEAD'])) return '';
|
|
46
|
+
// --keep refuses instead of losing anything: a change in progress that the reset would touch.
|
|
47
|
+
if (!ok(root, ['reset', '--quiet', '--keep', remote])) {
|
|
48
|
+
return `rsc · ${ahead} commit(s) en «${trunk}» que no están en el remoto, y este proyecto tiene «${trunk}» cerrada para el agente. ` +
|
|
49
|
+
`Los he copiado a la rama «${name}», pero no he podido dejar «${trunk}» como en el remoto porque hay cambios sin guardar que se verían afectados. ` +
|
|
50
|
+
`Díselo a la persona en una línea; cuando esos cambios estén a salvo: \`git reset --keep origin/${trunk}\`.`;
|
|
51
|
+
}
|
|
52
|
+
return `rsc · Había ${ahead} commit(s) en «${trunk}» que nunca llegaron al remoto, y en este proyecto «${trunk}» está cerrada para el agente. ` +
|
|
53
|
+
`Los he movido a la rama «${name}» y «${trunk}» vuelve a estar igual que en el remoto. Díselo a la persona en una línea.`;
|
|
54
|
+
} catch { return ''; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function relocateOldWorktrees(root) {
|
|
58
|
+
try {
|
|
59
|
+
const W = await import(new URL('./worktree-reaper.mjs', import.meta.url));
|
|
60
|
+
// Moving a folder is destructive, so this fails closed: without the memory nobody can be seen
|
|
61
|
+
// working, and then nothing is moved. A session in an old sibling keeps its journal in the
|
|
62
|
+
// sibling's own store (it has its own .rsc.json), so both stores are asked (review H3).
|
|
63
|
+
let M;
|
|
64
|
+
try { M = await import(new URL('./session-memory-core.mjs', import.meta.url)); } catch { return ''; }
|
|
65
|
+
const others = (path) => [
|
|
66
|
+
...M.otherActiveSessions({ cwd: root, worktreeCwd: path }),
|
|
67
|
+
...M.otherActiveSessions({ cwd: path, worktreeCwd: path }),
|
|
68
|
+
];
|
|
69
|
+
const home = real(root);
|
|
70
|
+
const moved = [];
|
|
71
|
+
const left = [];
|
|
72
|
+
for (const wt of W.listWorktrees(root)) {
|
|
73
|
+
const path = real(wt.path);
|
|
74
|
+
if (path === home || path.startsWith(home + sep)) continue; // already inside
|
|
75
|
+
if (W.provenanceOf(root, { ...wt, path }) !== 'rsc') continue;
|
|
76
|
+
if (others(path).length) { left.push(`${path} (una sesión trabaja en él)`); continue; }
|
|
77
|
+
const name = basename(path);
|
|
78
|
+
const dest = join(home, '.worktrees', name.startsWith(`${basename(home)}-`) ? name.slice(basename(home).length + 1) : name);
|
|
79
|
+
if (existsSync(dest)) { left.push(`${path} (ya existe ${dest})`); continue; }
|
|
80
|
+
mkdirSync(join(home, '.worktrees'), { recursive: true });
|
|
81
|
+
if (ok(root, ['worktree', 'move', path, dest])) moved.push(`${path} → .worktrees/${basename(dest)}`);
|
|
82
|
+
else left.push(`${path} (git no ha podido moverlo)`);
|
|
83
|
+
}
|
|
84
|
+
if (!moved.length && !left.length) return '';
|
|
85
|
+
return [
|
|
86
|
+
moved.length ? `rsc · Worktrees de versiones antiguas movidos dentro del proyecto: ${moved.join('; ')}.` : '',
|
|
87
|
+
left.length ? `rsc · Sin mover, se reintenta en otra sesión: ${left.join('; ')}.` : '',
|
|
88
|
+
'Díselo a la persona en una línea.',
|
|
89
|
+
].filter(Boolean).join(' ');
|
|
90
|
+
} catch { return ''; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MARK = '.team-safe-3';
|
|
94
|
+
|
|
95
|
+
export function teamSafeAnnouncement(root) {
|
|
96
|
+
try {
|
|
97
|
+
const mark = join(root, '.rsc', MARK);
|
|
98
|
+
if (existsSync(mark)) return '';
|
|
99
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
100
|
+
writeFileSync(mark, `${new Date().toISOString()}\n`);
|
|
101
|
+
return `
|
|
102
|
+
===== rsc 3.0 · equipo seguro por defecto =====
|
|
103
|
+
ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
|
|
104
|
+
1. En proyectos complejos o con producción, el agente no trabaja en la rama principal: abre ramas y
|
|
105
|
+
cierra con PR. En proyectos sencillos trabaja en la principal. Para abrirla: «desbloquea main».
|
|
106
|
+
2. Si otra sesión de un asistente está trabajando en esta misma carpeta, el trabajo nuevo va a un
|
|
107
|
+
worktree en .worktrees/<rama>/. Para no hacerlo: «no uses worktrees».
|
|
108
|
+
3. El agente elige solo entre FTD (lo sencillo) y SDD (lo grande o complejo) y lo dice; se puede
|
|
109
|
+
pedir el otro.
|
|
110
|
+
4. 01-TOOLS/ y 02-DOCS/ viajan al equipo por la rama rsc/knowledge y llegan a la principal dentro de
|
|
111
|
+
las PRs. Para apagarlo: rsc knowledge-sync off.
|
|
112
|
+
==============================================
|
|
113
|
+
`;
|
|
114
|
+
} catch { return ''; }
|
|
115
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Is the default branch closed for the agent in this project? (team-safe-default, part A)
|
|
2
|
+
//
|
|
3
|
+
// The spec leaves the call to the agent, change by change: complex or in production → branches;
|
|
4
|
+
// simple (scripts, research, personal) → straight on the default branch. That is judgement, and
|
|
5
|
+
// constitution P1 sends judgement to the skills. This module is only the FLOOR: the signals anyone
|
|
6
|
+
// can count — there is CI, there is a deployment, there is a team — which are the spec's own examples
|
|
7
|
+
// of "complejo o con producción". No signal: the floor stays open and the skill decides. A signal: the
|
|
8
|
+
// floor closes, and if the project is in fact simple the way out is the person asking to unlock it.
|
|
9
|
+
//
|
|
10
|
+
// `.rsc/.no-trunk-guard` is that unlock: a PROJECT switch (it travels in `.rsc.json` optOuts), set
|
|
11
|
+
// when somebody asks the agent «desbloquea main».
|
|
12
|
+
//
|
|
13
|
+
// Standalone on purpose: copied next to `branch-guard.mjs` and `knowledge-sync.mjs` under `.rsc/`.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const UNLOCK = '.no-trunk-guard';
|
|
19
|
+
|
|
20
|
+
const CI = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile',
|
|
21
|
+
'bitbucket-pipelines.yml', '.buildkite', '.travis.yml', '.woodpecker.yml', '.drone.yml'];
|
|
22
|
+
const DEPLOY = ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
|
|
23
|
+
'vercel.json', 'netlify.toml', 'fly.toml', 'render.yaml', 'railway.json', 'railway.toml', 'Procfile',
|
|
24
|
+
'app.yaml', 'serverless.yml', 'wrangler.toml', 'k8s', 'helm', 'Chart.yaml'];
|
|
25
|
+
const TEAM_COMMITS = 50;
|
|
26
|
+
|
|
27
|
+
function git(root, args) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const present = (root, rel) => {
|
|
34
|
+
const path = join(root, rel);
|
|
35
|
+
if (!existsSync(path)) return false;
|
|
36
|
+
try { return readdirSync(path).length > 0; } catch { return true; } // a file, not a folder
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The countable reasons this project looks complex or in production. Empty = looks simple. */
|
|
40
|
+
export function trunkSignals(root) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const ci = CI.find((rel) => present(root, rel));
|
|
43
|
+
if (ci) out.push(`CI (${ci})`);
|
|
44
|
+
const deploy = DEPLOY.find((rel) => present(root, rel));
|
|
45
|
+
if (deploy) out.push(`despliegue (${deploy})`);
|
|
46
|
+
const authors = new Set((git(root, ['log', `-${TEAM_COMMITS}`, '--format=%ae']) || '').split('\n').filter(Boolean));
|
|
47
|
+
if (authors.size >= 2) out.push(`equipo (${authors.size} personas en los últimos ${TEAM_COMMITS} commits)`);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The default branch's name: the remote's HEAD, else main/master, else null. */
|
|
52
|
+
export function defaultBranchName(root) {
|
|
53
|
+
const head = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
54
|
+
if (head) return head.replace(/^refs\/remotes\/origin\//, '');
|
|
55
|
+
for (const b of ['main', 'master']) {
|
|
56
|
+
if (git(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`])) return b;
|
|
57
|
+
if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${b}`])) return b;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `{ closed, reason, signals }` — reason: 'unlocked' | 'simple' | 'signals'. */
|
|
63
|
+
export function trunkPolicy(root) {
|
|
64
|
+
if (existsSync(join(root, '.rsc', UNLOCK))) return { closed: false, reason: 'unlocked', signals: [] };
|
|
65
|
+
const signals = trunkSignals(root);
|
|
66
|
+
return signals.length ? { closed: true, reason: 'signals', signals } : { closed: false, reason: 'simple', signals };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const trunkClosed = (root) => trunkPolicy(root).closed;
|