@ericrisco/rsc 2.1.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
+ }
@@ -18,12 +18,16 @@
18
18
  /** Decisions of the project: they belong in `.rsc.json` and are rebuilt in a clone. */
19
19
  export const PROJECT_OPT_OUTS = [
20
20
  'audit', // cadence of this project's skill audit
21
+ 'auto-update', // installing new rsc releases alone: one machine ahead moves the team's catalogVersion
21
22
  'claudemd-check', // the root CLAUDE.md context budget
22
23
  'danger-guard', // foot-gun denial, keyed to the committed user profile
23
24
  'feature-gate', // per-turn re-injection of the SDD gate
24
25
  'gitmoji', // the commit-message convention
26
+ 'knowledge-sync', // 01-TOOLS/ + 02-DOCS/ sync: one person off and the team stops seeing their work
25
27
  'ship-guard', // branch/trunk discipline
28
+ 'trunk-guard', // «desbloquea main»: the default branch open for the agent, for the whole team
26
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
27
31
  ];
28
32
 
29
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'];
@@ -6,6 +6,7 @@
6
6
  // workspace has no harness profile yet, and an auto-ingest nudge when there is
7
7
  // un-ingested material waiting in the inbox.
8
8
  import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { updateNotice } from './auto-update.mjs';
9
10
  import { join, resolve } from 'node:path';
10
11
  import { homedir } from 'node:os';
11
12
  import { claimOnce, readHookInput } from './hook-once.mjs';
@@ -252,80 +253,17 @@ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
252
253
  } catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
253
254
  }
254
255
 
255
- // Update check: compare the installed version (.rsc/.version, written at install)
256
- // against the latest published on npm, and nudge the agent to offer an update.
257
- // Fail-silent (offline / missing baseline / parse error → nothing). Disable with
258
- // RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
259
- function isNewer(a, b) {
260
- const pa = String(a).split('.').map(Number);
261
- const pb = String(b).split('.').map(Number);
262
- for (let i = 0; i < 3; i++) {
263
- if ((pa[i] || 0) > (pb[i] || 0)) return true;
264
- if ((pa[i] || 0) < (pb[i] || 0)) return false;
265
- }
266
- return false;
267
- }
268
-
269
- // "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
270
- // notice on every session, and two people in two days reported bugs that were already fixed. A
271
- // version number names nothing anyone recognises. A symptom does: someone who sees a red error after
272
- // every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
273
- // is wrong as a symptom, not as a cause).
274
- //
275
- // The symptoms travel for free: the registry's `/latest` document is the published package.json, so
276
- // its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
277
- // It is text a model will read, so every entry is reduced to one short plain line or dropped.
278
- const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
279
- function fixesSince(list, installed, latest) {
280
- if (!Array.isArray(list)) return [];
281
- const out = [];
282
- for (const item of list) {
283
- if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
284
- if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
285
- if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
286
- const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
287
- if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
288
- }
289
- return out.slice(0, 8);
290
- }
291
-
292
- if (!process.env.RSC_NO_UPDATE_CHECK) {
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')) {
293
259
  try {
294
- const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
295
- let doc = null;
296
- if (process.env.RSC_LATEST_JSON) doc = JSON.parse(process.env.RSC_LATEST_JSON);
297
- else if (process.env.RSC_LATEST) doc = { version: process.env.RSC_LATEST };
298
- else {
299
- const ctrl = new AbortController();
300
- const timer = setTimeout(() => ctrl.abort(), 1500);
301
- const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
302
- clearTimeout(timer);
303
- doc = await res.json();
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`);
304
263
  }
305
- const latest = doc?.version;
306
- if (installed && latest && isNewer(latest, installed)) {
307
- let fixes = [];
308
- try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
309
- if (fixes.length) {
310
- process.stdout.write(`
311
- ===== rsc update available =====
312
- rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
313
- ${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
314
- ACTION: before starting the task, tell the user in one line that the update fixes what they
315
- may be seeing, and if they say yes, run it in this project:
316
- npx @ericrisco/rsc@latest
317
- ================================
318
- `);
319
- } else {
320
- process.stdout.write(`
321
- ===== rsc update available =====
322
- rsc ${latest} is out — you have ${installed}.
323
- ACTION: tell the user a new version is available and, if they say yes, run:
324
- npx @ericrisco/rsc@latest
325
- (That reinstalls and refreshes the skill content to the latest.)
326
- ================================
327
- `);
328
- }
329
- }
330
- } catch { /* offline / no baseline / parse error → stay silent */ }
264
+ } catch { /* module missing → nothing to do; never worth breaking startup for */ }
331
265
  }
266
+
267
+ // Update check and auto-update: the rule lives in auto-update.mjs, shared with the other assistants.
268
+ // Disable with RSC_NO_UPDATE_CHECK=1.
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;