@ericrisco/rsc 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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, '', captured.notice, captured.compactionHint),
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;